--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 7df92c891be1cbbbc59ac3a50ad1c925c3765627
Parents : ae6829d
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-18T05:31:03-05:00
feat: update telephone API endpoints to use POST method and improve path safety checks
Changes
65 files changed, 2186 insertions(+), 1757 deletions(-)
Diff
diff --git a/meshchatx.rsm b/meshchatx.rsm
index c0d3f116..063added 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 534b5cc4..442ea6bd 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -223,6 +223,7 @@ from meshchatx.src.backend.sideband_commands import SidebandCommands
from meshchatx.src.backend.sideband_plugin_loader import SidebandPluginLoader
from meshchatx.src.backend.sticker_utils import (
build_export_document,
+ detect_image_format_from_magic,
mime_for_image_type,
sanitize_sticker_emoji,
sanitize_sticker_name,
@@ -238,6 +239,7 @@ from meshchatx.src.env_utils import env_bool
from meshchatx.src.path_utils import (
get_file_path,
resolve_log_dir,
+ safe_path_under_dir,
)
from meshchatx.src.path_utils import (
request_client_ip as _request_client_ip,
@@ -1929,7 +1931,7 @@ class ReticulumMeshChat:
@staticmethod
def _looks_like_meshchat_hot_reload_tail(pid: int, epoch: int) -> bool:
- """Limit repairs to suffixes :meth:`reload_reticulum` actually writes.
+ """Limit repairs to suffixes reload_reticulum actually writes.
Hot reload uses -reload-{os.getpid()}-{int(time.time())}. Names like
my-net-reload-peer must not be truncated.
@@ -10440,7 +10442,7 @@ class ReticulumMeshChat:
return web.json_response({"message": "ok"})
# answer incoming telephone call
- @routes.get("/api/v1/telephone/answer")
+ @routes.post("/api/v1/telephone/answer")
async def telephone_answer(request):
# get incoming caller identity
active_call = self.telephone_manager.telephone.active_call
@@ -10467,7 +10469,7 @@ class ReticulumMeshChat:
)
# hangup active telephone call
- @routes.get("/api/v1/telephone/hangup")
+ @routes.post("/api/v1/telephone/hangup")
async def telephone_hangup(request):
self.telephone_manager.request_hangup()
@@ -10478,7 +10480,7 @@ class ReticulumMeshChat:
)
# send active call to voicemail
- @routes.get("/api/v1/telephone/send-to-voicemail")
+ @routes.post("/api/v1/telephone/send-to-voicemail")
async def telephone_send_to_voicemail(request):
active_call = self.telephone_manager.telephone.active_call
if not active_call:
@@ -10501,23 +10503,23 @@ class ReticulumMeshChat:
)
# mute/unmute transmit
- @routes.get("/api/v1/telephone/mute-transmit")
+ @routes.post("/api/v1/telephone/mute-transmit")
async def telephone_mute_transmit(request):
await asyncio.to_thread(self.telephone_manager.mute_transmit)
return web.json_response({"message": "Microphone muted"})
- @routes.get("/api/v1/telephone/unmute-transmit")
+ @routes.post("/api/v1/telephone/unmute-transmit")
async def telephone_unmute_transmit(request):
await asyncio.to_thread(self.telephone_manager.unmute_transmit)
return web.json_response({"message": "Microphone unmuted"})
# mute/unmute receive
- @routes.get("/api/v1/telephone/mute-receive")
+ @routes.post("/api/v1/telephone/mute-receive")
async def telephone_mute_receive(request):
await asyncio.to_thread(self.telephone_manager.mute_receive)
return web.json_response({"message": "Speaker muted"})
- @routes.get("/api/v1/telephone/unmute-receive")
+ @routes.post("/api/v1/telephone/unmute-receive")
async def telephone_unmute_receive(request):
await asyncio.to_thread(self.telephone_manager.unmute_receive)
return web.json_response({"message": "Speaker unmuted"})
@@ -10583,7 +10585,7 @@ class ReticulumMeshChat:
return web.json_response({"message": "ok"})
# switch audio profile
- @routes.get("/api/v1/telephone/switch-audio-profile/{profile_id}")
+ @routes.post("/api/v1/telephone/switch-audio-profile/{profile_id}")
async def telephone_switch_audio_profile(request):
profile_id = request.match_info.get("profile_id")
try:
@@ -10637,7 +10639,7 @@ class ReticulumMeshChat:
# initiate a telephone call
# initiate outgoing telephone call
- @routes.get("/api/v1/telephone/call/{identity_hash}")
+ @routes.post("/api/v1/telephone/call/{identity_hash}")
async def telephone_call(request):
# make sure telephone enabled
if self.telephone_manager.telephone is None:
@@ -10817,11 +10819,11 @@ class ReticulumMeshChat:
voicemail_id = request.match_info.get("id")
voicemail = self.database.voicemails.get_voicemail(voicemail_id)
if voicemail:
- filepath = os.path.join(
+ filepath = safe_path_under_dir(
self.voicemail_manager.recordings_dir,
voicemail["filename"],
)
- if os.path.exists(filepath):
+ if filepath and os.path.exists(filepath):
os.remove(filepath)
self.database.voicemails.delete_voicemail(voicemail_id)
return web.json_response({"message": "Voicemail deleted"})
@@ -10864,11 +10866,11 @@ class ReticulumMeshChat:
voicemail = self.database.voicemails.get_voicemail(voicemail_id)
if voicemail:
- filepath = os.path.join(
+ filepath = safe_path_under_dir(
self.voicemail_manager.recordings_dir,
voicemail["filename"],
)
- if os.path.exists(filepath):
+ if filepath and os.path.exists(filepath):
# Browsers might need a proper content type for .opus files
return web.FileResponse(
filepath,
@@ -10932,11 +10934,11 @@ class ReticulumMeshChat:
status=404,
)
- filepath = os.path.join(
+ filepath = safe_path_under_dir(
self.telephone_manager.recordings_dir,
filename,
)
- if os.path.exists(filepath):
+ if filepath and os.path.exists(filepath):
return web.FileResponse(
filepath,
headers={"Content-Type": "audio/opus"},
@@ -10953,11 +10955,11 @@ class ReticulumMeshChat:
for side in ["rx", "tx"]:
filename = recording[f"filename_{side}"]
if filename:
- filepath = os.path.join(
+ filepath = safe_path_under_dir(
self.telephone_manager.recordings_dir,
filename,
)
- if os.path.exists(filepath):
+ if filepath and os.path.exists(filepath):
os.remove(filepath)
self.database.telephone.delete_call_recording(recording_id)
return web.json_response({"message": "ok"})
@@ -11125,12 +11127,19 @@ class ReticulumMeshChat:
filepath = self.ringtone_manager.get_ringtone_path(
ringtone["storage_filename"],
)
- if os.path.exists(filepath):
+ if filepath and os.path.exists(filepath):
if download:
+ safe_name = os.path.basename(
+ str(ringtone.get("filename") or "ringtone.opus"),
+ )
+ safe_name = (
+ safe_name.replace('"', "").replace("\r", "").replace("\n", "")
+ or "ringtone.opus"
+ )
return web.FileResponse(
filepath,
headers={
- "Content-Disposition": f'attachment; filename="{ringtone["filename"]}"',
+ "Content-Disposition": f'attachment; filename="{safe_name}"',
},
)
return web.FileResponse(filepath)
@@ -11298,14 +11307,19 @@ class ReticulumMeshChat:
filepath = self.notification_sound_manager.get_ringtone_path(
sound["storage_filename"],
)
- if not os.path.exists(filepath):
+ if not filepath or not os.path.exists(filepath):
return web.Response(status=404)
+ safe_name = os.path.basename(str(sound.get("filename") or "sound.opus"))
+ safe_name = (
+ safe_name.replace('"', "").replace("\r", "").replace("\n", "")
+ or "sound.opus"
+ )
return web.FileResponse(
filepath,
headers={
"Content-Type": "audio/ogg",
- "Content-Disposition": f'attachment; filename="{sound["filename"]}"',
+ "Content-Disposition": f'attachment; filename="{safe_name}"',
},
)
@@ -11701,7 +11715,7 @@ class ReticulumMeshChat:
blocked_identity_hashes = [b["destination_hash"] for b in blocked]
if search_query:
- # `limit` here is the caller's desired page size for the
+ # limit here is the caller's desired page size for the
# paginated, filtered results below, not the number of rows
# to scan for matches. Always scan up to search_max rows so
# matches outside the most-recent page are still found.
@@ -14671,8 +14685,14 @@ class ReticulumMeshChat:
try:
if "image" in fields and isinstance(fields.get("image"), dict):
- image_type = fields["image"]["image_type"]
image_bytes = base64.b64decode(fields["image"]["image_bytes"])
+ detected = detect_image_format_from_magic(image_bytes)
+ if detected is None or detected in {"webm", "tgs"}:
+ return web.json_response(
+ {"message": "Invalid image attachment"},
+ status=400,
+ )
+ image_type = "jpg" if detected == "jpeg" else detected
image_field = LxmfImageField(image_type, image_bytes)
if "audio" in fields and isinstance(fields.get("audio"), dict):
@@ -14966,12 +14986,14 @@ class ReticulumMeshChat:
status=400,
)
allowed_image_types = {"png", "jpeg", "jpg", "gif", "webp", "bmp"}
- image_type = image_field.get("image_type") or "png"
- if not isinstance(image_type, str):
- image_type = "png"
- image_type = image_type.lower().replace("image/", "").strip() or "png"
- if image_type not in allowed_image_types:
- image_type = "png"
+ detected = detect_image_format_from_magic(image_data)
+ if detected is None or detected not in allowed_image_types:
+ return web.json_response(
+ {"message": "Invalid image attachment"},
+ status=400,
+ )
+ # Serve Content-Type from magic bytes, not the peer-declared type.
+ image_type = "jpeg" if detected == "jpeg" else detected
return web.Response(body=image_data, content_type=f"image/{image_type}")
# handle audio
@@ -19101,7 +19123,7 @@ class ReticulumMeshChat:
combined_data = {}
# parse data from page path
- # example: hash:/page/index.mu`field1=123|field2=456
+ # example path then backtick then field1=123|field2=456
page_data = None
page_path_to_download = page_path
if "`" in page_path:
@@ -19119,7 +19141,10 @@ class ReticulumMeshChat:
combined_data.update(field_data)
# convert destination hash to bytes
- destination_hash = bytes.fromhex(destination_hash)
+ try:
+ destination_hash = bytes.fromhex(destination_hash)
+ except (TypeError, ValueError):
+ return
local_page = self._try_serve_local_page_node(
destination_hash,
@@ -20928,7 +20953,7 @@ class ReticulumMeshChat:
"""Encode a WAV/PCM payload into an OGG/Opus byte string.
Thin compatibility wrapper around
- :func:`meshchatx.src.backend.audio_codec.encode_audio_bytes_to_ogg_opus`
+ meshchatx.src.backend.audio_codec.encode_audio_bytes_to_ogg_opus
kept for the existing test surface.
"""
try:
@@ -20943,7 +20968,7 @@ class ReticulumMeshChat:
"""Convert browser-recorded audio into LXMF-compatible OGG/Opus.
Routes everything through
- :mod:`meshchatx.src.backend.audio_codec`, which decodes the input
+ meshchatx.src.backend.audio_codec, which decodes the input
with miniaudio (WAV/MP3/FLAC/OGG-Vorbis) or LXST (OGG/Opus) and
re-encodes it with LXST's voice-friendly Opus profile. If decoding
fails the original bytes are returned unchanged so the caller can
diff --git a/meshchatx/src/__init__.py b/meshchatx/src/__init__.py
index 2ee29f9e..f07d783d 100644
--- a/meshchatx/src/__init__.py
+++ b/meshchatx/src/__init__.py
@@ -3,7 +3,7 @@
import sys
# NOTE: this class is required to be able to use print/log commands and have them flush to stdout and stderr immediately
-# without wrapper stdout and stderr, when using `childProcess.stdout.on('data', ...)` in NodeJS script, we never get
+# without wrapper stdout and stderr, when using childProcess.stdout.on(data) in NodeJS script, we never get
# any events fired until the process exits. However, force flushing the streams does fire the callbacks in NodeJS.
diff --git a/meshchatx/src/backend/audio_codec.py b/meshchatx/src/backend/audio_codec.py
index a6a51a67..2eeb6fe8 100644
--- a/meshchatx/src/backend/audio_codec.py
+++ b/meshchatx/src/backend/audio_codec.py
@@ -10,7 +10,7 @@ LXST.Sinks.OpusFileSink.
Decoders, in priority order:
1. wave (built-in) for RIFF/WAVE containers.
-2. `miniaudio <https://pypi.org/project/miniaudio/>`_ for WAV, MP3, FLAC
+2. miniaudio (https://pypi.org/project/miniaudio/) for WAV, MP3, FLAC
and OGG/Vorbis. Bundled as a runtime dependency on every supported
target (including Android via the Chaquopy recipe under
android/chaquopy-recipes/miniaudio-1.70).
diff --git a/meshchatx/src/backend/database/gifs.py b/meshchatx/src/backend/database/gifs.py
index 5889ec1d..705bdae4 100644
--- a/meshchatx/src/backend/database/gifs.py
+++ b/meshchatx/src/backend/database/gifs.py
@@ -10,7 +10,7 @@ from meshchatx.src.backend import gif_utils
class UserGifsDAO:
"""Per-identity library of user-uploaded GIFs.
- Mirrors :class:`UserStickersDAO` but exposes a usage_count/last_used_at
+ Mirrors UserStickersDAO but exposes a usage_count/last_used_at
pair so the picker can order entries by most-used and the user can quickly
reuse their favorite GIFs across chats.
"""
diff --git a/meshchatx/src/backend/database/messages.py b/meshchatx/src/backend/database/messages.py
index 1eb4aed7..a2800e73 100644
--- a/meshchatx/src/backend/database/messages.py
+++ b/meshchatx/src/backend/database/messages.py
@@ -449,7 +449,7 @@ class MessageDAO:
"""Return the most recent incoming user-facing message for peer_hash.
Walks recent incoming messages in timestamp-descending order and applies
- :func:`is_user_facing_lxmf_payload` in Python (the SQLite layer cannot
+ is_user_facing_lxmf_payload in Python (the SQLite layer cannot
cheaply parse the JSON fields blob). scan_limit bounds the walk
so a long chain of reactions/telemetry won't degrade the bell endpoint.
diff --git a/meshchatx/src/backend/http_url_guard.py b/meshchatx/src/backend/http_url_guard.py
index c19d2d27..d00c8373 100644
--- a/meshchatx/src/backend/http_url_guard.py
+++ b/meshchatx/src/backend/http_url_guard.py
@@ -114,13 +114,21 @@ def normalize_libretranslate_http_service_base(url: str) -> str:
host_for_ip_check = host_decoded.lower().strip("[]")
addr = _coerce_host_to_ip(host_for_ip_check)
if addr is not None:
- if addr.version == 4 and addr.is_link_local:
- msg = "URL must not target an IPv4 link-local address"
+ # Apply policy to the effective IPv4 when the host is IPv4-mapped IPv6
+ # (::ffff:169.254.169.254), and reject native IPv6 link-local (fe80::/10).
+ effective = addr.ipv4_mapped if getattr(addr, "ipv4_mapped", None) else addr
+ if effective.is_link_local or addr.is_link_local:
+ msg = "URL must not target a link-local address"
raise UnsafeOutboundUrlError(msg)
- if addr.is_multicast or addr.is_unspecified:
+ if (
+ effective.is_multicast
+ or effective.is_unspecified
+ or addr.is_multicast
+ or addr.is_unspecified
+ ):
msg = "URL must not target a multicast or unspecified address"
raise UnsafeOutboundUrlError(msg)
- if addr.is_reserved:
+ if effective.is_reserved or addr.is_reserved:
msg = "URL must not target a reserved address"
raise UnsafeOutboundUrlError(msg)
diff --git a/meshchatx/src/backend/log_redaction.py b/meshchatx/src/backend/log_redaction.py
index 40027c41..feb4bd7e 100644
--- a/meshchatx/src/backend/log_redaction.py
+++ b/meshchatx/src/backend/log_redaction.py
@@ -38,6 +38,7 @@ _PEM_RE = re.compile(
# Bearer / JWT-ish tokens and common secret assignments.
_BEARER_RE = re.compile(r"\bBearer\s+[A-Za-z0-9._\-+=/]{8,}", re.IGNORECASE)
+_BASIC_AUTH_RE = re.compile(r"\bBasic\s+[A-Za-z0-9+/=]{8,}", re.IGNORECASE)
_SECRET_ASSIGN_RE = re.compile(
r"\b(?:alias_identity_private_key|private_key|session|password|passwd|token|"
r"api[_-]?key|csrf|authorization)\s*[:=]\s*\S+",
@@ -62,5 +63,6 @@ def redact_diagnostic_text(text: str) -> str:
out = _EMAIL_RE.sub(REDACTED, out)
out = _IPV4_RE.sub(REDACTED, out)
out = _BEARER_RE.sub(f"Bearer {REDACTED}", out)
+ out = _BASIC_AUTH_RE.sub(f"Basic {REDACTED}", out)
out = _SECRET_ASSIGN_RE.sub(REDACTED, out)
return out
diff --git a/meshchatx/src/backend/lxmf_utils.py b/meshchatx/src/backend/lxmf_utils.py
index 971f604e..96da08db 100644
--- a/meshchatx/src/backend/lxmf_utils.py
+++ b/meshchatx/src/backend/lxmf_utils.py
@@ -146,7 +146,7 @@ def is_user_facing_lxmf_payload(fields, content, title) -> bool:
treated as user-facing so the bell and previews stay informative.
The helper is intentionally tolerant: fields may be the rich dict
- produced by :func:`convert_lxmf_message_to_dict` (string keys), the raw
+ produced by convert_lxmf_message_to_dict (string keys), the raw
LXMF integer-keyed dict, or a JSON-string from the database.
"""
import json as _json
diff --git a/meshchatx/src/backend/map_overlay_sources.py b/meshchatx/src/backend/map_overlay_sources.py
index e6dffefc..8cce0f12 100644
--- a/meshchatx/src/backend/map_overlay_sources.py
+++ b/meshchatx/src/backend/map_overlay_sources.py
@@ -86,11 +86,13 @@ def is_commit_like_ref(ref: str) -> bool:
def _safe_repo_relpath(path: str) -> str:
- if not isinstance(path, str):
+ if not isinstance(path, str) or "\x00" in path:
raise OverlaySourceParseError("invalid_path")
p = path.strip().replace("\\", "/")
if not p or p.startswith("/") or p.startswith("~"):
raise OverlaySourceParseError("invalid_path")
+ if ":" in p:
+ raise OverlaySourceParseError("invalid_path")
parts = [seg for seg in p.split("/") if seg and seg != "."]
if not parts or any(seg == ".." for seg in parts):
raise OverlaySourceParseError("path_traversal")
@@ -101,7 +103,7 @@ def _safe_repo_relpath(path: str) -> str:
def _safe_nomadnet_file_path(path: str) -> str:
- if not isinstance(path, str):
+ if not isinstance(path, str) or "\x00" in path:
raise OverlaySourceParseError("invalid_path")
p = path.strip().replace("\\", "/")
if p.startswith("file/"):
@@ -109,7 +111,7 @@ def _safe_nomadnet_file_path(path: str) -> str:
if not p.startswith("/file/"):
raise OverlaySourceParseError("not_file_path")
rest = p[len("/file/") :]
- if not rest or rest.endswith("/"):
+ if not rest or rest.endswith("/") or "\x00" in rest or ":" in rest:
raise OverlaySourceParseError("invalid_path")
parts = [seg for seg in rest.split("/") if seg and seg != "."]
if not parts or any(seg == ".." for seg in parts):
diff --git a/meshchatx/src/backend/markdown_renderer.py b/meshchatx/src/backend/markdown_renderer.py
index cb906a8a..54af0f07 100644
--- a/meshchatx/src/backend/markdown_renderer.py
+++ b/meshchatx/src/backend/markdown_renderer.py
@@ -78,7 +78,7 @@ class MarkdownRenderer:
)
# Inline code before emphasis so snake_case / rst spans are not
- # mangled by underscore italic (changelog uses both `code` and code).
+ # mangled by underscore italic (changelog uses both code spans).
inline_codes: list[str] = []
def inline_code_placeholder(match):
diff --git a/meshchatx/src/backend/plugin_guard.py b/meshchatx/src/backend/plugin_guard.py
index 15a00ecd..bf72eb13 100644
--- a/meshchatx/src/backend/plugin_guard.py
+++ b/meshchatx/src/backend/plugin_guard.py
@@ -19,6 +19,8 @@ class PluginSecurityError(ValueError):
def normalize_asset_path(asset_name: str) -> str:
+ if not isinstance(asset_name, str) or "\x00" in asset_name:
+ raise PluginSecurityError("invalid asset path")
normalized = os.path.normpath(asset_name).replace("\\", "/")
if not normalized or normalized in {".", ".."}:
raise PluginSecurityError("invalid asset path")
@@ -26,6 +28,9 @@ def normalize_asset_path(asset_name: str) -> str:
raise PluginSecurityError("invalid asset path")
if "/../" in f"/{normalized}/":
raise PluginSecurityError("invalid asset path")
+ # Reject Windows drive-absolute forms (C:/...) that can escape on win32 joins.
+ if ":" in normalized:
+ raise PluginSecurityError("invalid asset path")
return normalized
@@ -45,11 +50,17 @@ def validate_invoke_payload(payload: bytes) -> None:
def _zip_entry_is_safe(name: str) -> bool:
+ if not isinstance(name, str) or "\x00" in name:
+ return False
normalized = os.path.normpath(name).replace("\\", "/")
if normalized.startswith("../") or normalized.startswith("/"):
return False
if normalized in {"", ".", ".."}:
return False
+ if ":" in normalized:
+ return False
+ if "/../" in f"/{normalized}/":
+ return False
return True
diff --git a/meshchatx/src/backend/plugin_manager.py b/meshchatx/src/backend/plugin_manager.py
index fc4e084b..525b82ca 100644
--- a/meshchatx/src/backend/plugin_manager.py
+++ b/meshchatx/src/backend/plugin_manager.py
@@ -771,7 +771,10 @@ class PluginManager:
self._require_runtime_enabled()
record = self._require_plugin(plugin_id)
normalized = normalize_asset_path(asset_name)
- path = os.path.join(record.install_path, normalized)
+ root = os.path.realpath(record.install_path)
+ path = os.path.realpath(os.path.join(root, normalized))
+ if path != root and not path.startswith(root + os.sep):
+ raise PluginSecurityError("invalid asset path")
if not os.path.isfile(path):
raise FileNotFoundError(asset_name)
return path
diff --git a/meshchatx/src/backend/repository_server_manager.py b/meshchatx/src/backend/repository_server_manager.py
index d9608d26..4fd9694d 100644
--- a/meshchatx/src/backend/repository_server_manager.py
+++ b/meshchatx/src/backend/repository_server_manager.py
@@ -214,7 +214,7 @@ def download_bundled_wheels_to_directory(
*,
on_package: Callable[[int, int, str], None] | None = None,
) -> dict[str, Any]:
- """Populate dest with wheels for :func:`bundled_pip_targets`.
+ """Populate dest with wheels for bundled_pip_targets.
Uses PyPI project metadata JSON and HTTPS downloads via urllib only.
"""
diff --git a/meshchatx/src/backend/ringtone_manager.py b/meshchatx/src/backend/ringtone_manager.py
index 89aa0a61..bed56033 100644
--- a/meshchatx/src/backend/ringtone_manager.py
+++ b/meshchatx/src/backend/ringtone_manager.py
@@ -45,10 +45,28 @@ class RingtoneManager:
return filename
def remove_ringtone(self, filename):
- opus_path = os.path.join(self.storage_dir, filename)
+ safe = self._safe_storage_filename(filename)
+ if safe is None:
+ return False
+ opus_path = os.path.join(self.storage_dir, safe)
if os.path.exists(opus_path):
os.remove(opus_path)
return True
def get_ringtone_path(self, filename):
- return os.path.join(self.storage_dir, filename)
+ safe = self._safe_storage_filename(filename)
+ if safe is None:
+ return None
+ path = os.path.realpath(os.path.join(self.storage_dir, safe))
+ root = os.path.realpath(self.storage_dir)
+ if path != root and not path.startswith(root + os.sep):
+ return None
+ return path
+
+ def _safe_storage_filename(self, filename):
+ if not isinstance(filename, str) or not filename or "\x00" in filename:
+ return None
+ base = os.path.basename(filename.replace("\\", "/"))
+ if not base or base in {".", ".."} or ":" in base:
+ return None
+ return base
diff --git a/meshchatx/src/backend/rns_link_manager.py b/meshchatx/src/backend/rns_link_manager.py
index b737e62d..fe6ec535 100644
--- a/meshchatx/src/backend/rns_link_manager.py
+++ b/meshchatx/src/backend/rns_link_manager.py
@@ -176,7 +176,7 @@ def _reset_failure_count(key: tuple[str, bytes]) -> None:
def _record_failure_and_maybe_recycle(key: tuple[str, bytes]) -> tuple[int, bool]:
- """Increment the failure counter for `key`.
+ """Increment the failure counter for key.
If the threshold is reached, pop the cached link, clear the counter,
and tear the link down outside the lock (teardown synchronously
diff --git a/meshchatx/src/backend/rrc/__init__.py b/meshchatx/src/backend/rrc/__init__.py
index 2bdd9273..fb8adebb 100644
--- a/meshchatx/src/backend/rrc/__init__.py
+++ b/meshchatx/src/backend/rrc/__init__.py
@@ -3,10 +3,10 @@
"""Reticulum Relay Chat (RRC) implementation for MeshChatX.
This package provides a wire-compatible client for RRC hubs (rrcd) and the
-ability to host hubs locally. The protocol layer in :mod:`protocol` is free of
+ability to host hubs locally. The protocol layer in protocol is free of
any Reticulum dependency so the encoding rules and parsers can be unit tested in
-isolation. :mod:`manager` contains the client link handling and session state,
-while :mod:`server` hosts one or more local hubs built on the Reticulum Network
+isolation. manager contains the client link handling and session state,
+while server hosts one or more local hubs built on the Reticulum Network
Stack.
"""
diff --git a/meshchatx/src/backend/rrc/manager.py b/meshchatx/src/backend/rrc/manager.py
index 38dbd53b..d17b7a6d 100644
--- a/meshchatx/src/backend/rrc/manager.py
+++ b/meshchatx/src/backend/rrc/manager.py
@@ -2,8 +2,8 @@
"""Reticulum Relay Chat session management.
-Contains :class:`RRCHub`, which owns the Reticulum link and protocol state for a
-single hub, and :class:`RRCManager`, which tracks the set of configured hubs,
+Contains RRCHub, which owns the Reticulum link and protocol state for a
+single hub, and RRCManager, which tracks the set of configured hubs,
persists them, and relays change and message notifications to the application.
"""
diff --git a/meshchatx/src/backend/rrc/server.py b/meshchatx/src/backend/rrc/server.py
index b3eeaed7..e7694217 100644
--- a/meshchatx/src/backend/rrc/server.py
+++ b/meshchatx/src/backend/rrc/server.py
@@ -2,9 +2,9 @@
"""Reticulum Relay Chat hub hosting.
-Provides :class:`RRCHubServer`, a self-contained RRC hub that listens on a
+Provides RRCHubServer, a self-contained RRC hub that listens on a
Reticulum destination and relays messages between connected clients, and
-:class:`RRCServerManager`, which lets a node host several independent hubs and
+RRCServerManager, which lets a node host several independent hubs and
manage their public rooms. The wire behaviour mirrors the reference rrcd hub so
standard RRC clients (including the MeshChatX client) can connect.
"""
@@ -59,7 +59,7 @@ class _LoopbackEndpoint:
Reticulum does not loop packets back to destinations hosted within the same
instance, so connecting to a locally hosted hub goes through this direct
bridge instead of the mesh. It exposes just enough of the
- :class:`RNS.Link` surface for both sides to treat it like a link.
+ RNS.Link surface for both sides to treat it like a link.
"""
def __init__(self, client_hub, server):
diff --git a/meshchatx/src/backend/sticker_utils.py b/meshchatx/src/backend/sticker_utils.py
index 4bc314a7..3fe12a16 100644
--- a/meshchatx/src/backend/sticker_utils.py
+++ b/meshchatx/src/backend/sticker_utils.py
@@ -678,7 +678,7 @@ def validate_export_document(data: object) -> list[dict]:
def build_export_document(stickers: list[dict], exported_at_iso: str) -> dict:
- """Build a `meshchatx-stickers` JSON document for individual stickers."""
+ """Build a meshchatx-stickers JSON document for individual stickers."""
return {
"format": _EXPORT_FORMAT,
"version": _EXPORT_VERSION,
diff --git a/meshchatx/src/frontend/components/CommandPalette.vue b/meshchatx/src/frontend/components/CommandPalette.vue
index 1828c344..b78ec04a 100644
--- a/meshchatx/src/frontend/components/CommandPalette.vue
+++ b/meshchatx/src/frontend/components/CommandPalette.vue
@@ -294,7 +294,7 @@ export default {
},
async dialContact(hash) {
try {
- await window.api.get(`/api/v1/telephone/call/${hash}`);
+ await window.api.post(`/api/v1/telephone/call/${hash}`);
if (this.$route.name !== "call") {
this.$router.push({ name: "call" });
}
diff --git a/meshchatx/src/frontend/components/archives/ArchivesPage.vue b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
index 2ce61715..a1d5f1d6 100644
--- a/meshchatx/src/frontend/components/archives/ArchivesPage.vue
+++ b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
@@ -229,7 +229,7 @@ import {
invalidateNomadMicronWasmPreload,
isMicronWasmBundled,
} from "../../js/MicronWasmLoader.js";
-import { renderNomadPageByPath } from "../../js/NomadPageRenderer.js";
+import { renderNomadPageByPath, isolateNomadLinksInHtml } from "../../js/NomadPageRenderer.js";
import { handleRichHtmlLinkClick } from "../../js/NomadRichHtmlLinks.js";
import ArchiveSidebar from "./ArchiveSidebar.vue";
@@ -598,11 +598,19 @@ export default {
const micronOpts = {
useWasm: this.nomadMicronWasmActive,
};
+ const destinationHash = archive.destination_hash || this.viewingArchive?.destination_hash || null;
try {
if (!hasKnownExt && archive.content.includes("`")) {
- return new MicronParser().convertMicronToHtml(archive.content, {}, micronOpts);
+ let out = new MicronParser().convertMicronToHtml(archive.content, {}, micronOpts);
+ if (destinationHash) {
+ out = isolateNomadLinksInHtml(out, destinationHash);
+ }
+ return out;
}
- return renderNomadPageByPath(pathPart, archive.content, {}, MicronParser, this.nomadRenderOptions);
+ return renderNomadPageByPath(pathPart, archive.content, {}, MicronParser, {
+ ...this.nomadRenderOptions,
+ nomadDestinationHash: destinationHash || this.nomadRenderOptions.nomadDestinationHash,
+ });
} catch (e) {
console.error("Archive render failed", e);
return String(archive.content)
diff --git a/meshchatx/src/frontend/components/call/CallOverlay.vue b/meshchatx/src/frontend/components/call/CallOverlay.vue
index ccb12b46..44653fa2 100644
--- a/meshchatx/src/frontend/components/call/CallOverlay.vue
+++ b/meshchatx/src/frontend/components/call/CallOverlay.vue
@@ -418,7 +418,7 @@ export default {
},
async answerCall() {
try {
- await window.api.get("/api/v1/telephone/answer");
+ await window.api.post("/api/v1/telephone/answer");
// Native Android audio (and desktop web-audio) only attach from
// CallPage. Overlay accept must open the phone tab or the call
// stays silent after answer.
@@ -432,14 +432,14 @@ export default {
async hangupCall() {
try {
this.$emit("hangup");
- await window.api.get("/api/v1/telephone/hangup");
+ await window.api.post("/api/v1/telephone/hangup");
} catch {
ToastUtils.error(this.$t("call.failed_to_hangup_call"));
}
},
async sendToVoicemail() {
try {
- await window.api.get("/api/v1/telephone/send-to-voicemail");
+ await window.api.post("/api/v1/telephone/send-to-voicemail");
ToastUtils.success(this.$t("call.call_sent_to_voicemail"));
} catch {
ToastUtils.error(this.$t("call.failed_to_send_to_voicemail"));
@@ -457,7 +457,7 @@ export default {
const endpoint = isCurrentlyMuted
? "/api/v1/telephone/unmute-transmit"
: "/api/v1/telephone/mute-transmit";
- await window.api.get(endpoint);
+ await window.api.post(endpoint);
setTimeout(() => {
this.isMicMuting = false;
@@ -481,7 +481,7 @@ export default {
const endpoint = isCurrentlyMuted
? "/api/v1/telephone/unmute-receive"
: "/api/v1/telephone/mute-receive";
- await window.api.get(endpoint);
+ await window.api.post(endpoint);
setTimeout(() => {
this.isSpeakerMuting = false;
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 06f924d3..60fb346a 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -4306,7 +4306,7 @@ export default {
this.wasDeclined = false;
try {
- await window.api.get(`/api/v1/telephone/call/${hashToCall}`);
+ await window.api.post(`/api/v1/telephone/call/${hashToCall}`);
} catch (e) {
this.initiationStatus = null;
ToastUtils.error(e.response?.data?.message || "Failed to initiate call");
@@ -4353,7 +4353,7 @@ export default {
},
async answerCall() {
try {
- await window.api.get("/api/v1/telephone/answer");
+ await window.api.post("/api/v1/telephone/answer");
} catch {
ToastUtils.error(this.$t("call.failed_to_answer_call"));
}
@@ -4363,14 +4363,14 @@ export default {
if (this.activeCall && this.activeCall.is_incoming && this.activeCall.status === 4) {
this.wasDeclined = true;
}
- await window.api.get("/api/v1/telephone/hangup");
+ await window.api.post("/api/v1/telephone/hangup");
} catch {
ToastUtils.error(this.$t("call.failed_to_hangup_call"));
}
},
async sendToVoicemail() {
try {
- await window.api.get("/api/v1/telephone/send-to-voicemail");
+ await window.api.post("/api/v1/telephone/send-to-voicemail");
ToastUtils.success(this.$t("call.call_sent_to_voicemail"));
} catch {
ToastUtils.error(this.$t("call.failed_to_send_to_voicemail"));
@@ -4378,7 +4378,7 @@ export default {
},
async switchAudioProfile(audioProfileId) {
try {
- const response = await window.api.get(`/api/v1/telephone/switch-audio-profile/${audioProfileId}`);
+ const response = await window.api.post(`/api/v1/telephone/switch-audio-profile/${audioProfileId}`);
const resolved = response.data?.profile_id;
if (resolved != null) {
this.selectedAudioProfileId = resolved;
@@ -4404,7 +4404,7 @@ export default {
const endpoint = isCurrentlyMuted
? "/api/v1/telephone/unmute-transmit"
: "/api/v1/telephone/mute-transmit";
- await window.api.get(endpoint);
+ await window.api.post(endpoint);
setTimeout(() => {
this.isMicMuting = false;
}, 500);
@@ -4429,7 +4429,7 @@ export default {
const endpoint = isCurrentlyMuted
? "/api/v1/telephone/unmute-receive"
: "/api/v1/telephone/mute-receive";
- await window.api.get(endpoint);
+ await window.api.post(endpoint);
setTimeout(() => {
this.isSpeakerMuting = false;
}, 500);
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 6a50754d..7623da60 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -2519,6 +2519,8 @@ export default {
// listen for contact updates to refresh stranger banner
GlobalEmitter.on("contact-updated", this.onContactUpdatedForBanner);
+ GlobalEmitter.on("identity-switched", this.onIdentitySwitched);
+
// check translator
this.checkTranslator();
@@ -2571,6 +2573,7 @@ export default {
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
GlobalEmitter.off("contact-updated", this.onContactUpdatedForBanner);
+ GlobalEmitter.off("identity-switched", this.onIdentitySwitched);
if (this.propagationStatusInterval) {
clearInterval(this.propagationStatusInterval);
}
@@ -3197,8 +3200,16 @@ export default {
},
loadDraft(destinationHash) {
try {
- const drafts = JSON.parse(localStorage.getItem("meshchat.drafts") || "{}");
- this.newMessageText = drafts[destinationHash] || "";
+ const drafts = this._readDraftRoot();
+ const bucket = this._draftBucket(drafts);
+ let text = "";
+ if (bucket && typeof bucket[destinationHash] === "string") {
+ text = bucket[destinationHash];
+ } else if (typeof drafts[destinationHash] === "string") {
+ // Legacy flat keys (pre identity-scoped drafts).
+ text = drafts[destinationHash];
+ }
+ this.newMessageText = text;
this.$nextTick(() => {
this.adjustTextareaHeight();
});
@@ -3208,17 +3219,55 @@ export default {
},
saveDraft(destinationHash) {
try {
- const drafts = JSON.parse(localStorage.getItem("meshchat.drafts") || "{}");
+ const drafts = this._readDraftRoot();
+ const identityKey = this._draftIdentityKey();
+ let bucket = this._draftBucket(drafts);
+ if (!bucket) {
+ bucket = {};
+ drafts[identityKey] = bucket;
+ if (typeof drafts[destinationHash] === "string") {
+ delete drafts[destinationHash];
+ }
+ }
if (this.newMessageText) {
- drafts[destinationHash] = this.newMessageText;
+ bucket[destinationHash] = this.newMessageText;
} else {
- delete drafts[destinationHash];
+ delete bucket[destinationHash];
}
localStorage.setItem("meshchat.drafts", JSON.stringify(drafts));
} catch (e) {
console.error("Failed to save draft:", e);
}
},
+ _draftIdentityKey() {
+ const hash = this.config?.identity_hash || this.myLxmfAddressHash || "";
+ return typeof hash === "string" && hash ? hash : "_";
+ },
+ _readDraftRoot() {
+ const raw = JSON.parse(localStorage.getItem("meshchat.drafts") || "{}");
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
+ },
+ _draftBucket(drafts) {
+ const identityKey = this._draftIdentityKey();
+ const nested = drafts[identityKey];
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
+ return nested;
+ }
+ return null;
+ },
+ onIdentitySwitched() {
+ if (this.selectedPeer?.destination_hash) {
+ this.saveDraft(this.selectedPeer.destination_hash);
+ }
+ this.lxmfMessagesRequestSequence += 1;
+ this.chatItems = [];
+ this.messageBubbleTranslation = {};
+ this.clearAudioAttachmentCache();
+ if (this.selectedPeer) {
+ this.loadDraft(this.selectedPeer.destination_hash);
+ this.initialLoad();
+ }
+ },
close() {
this.$emit("close");
},
@@ -4654,10 +4703,8 @@ export default {
mime = `image/${raw}`;
} else if (raw === "webm") {
mime = "video/webm";
- } else if (raw === "svg" || raw === "svg+xml") {
- mime = "image/svg+xml";
} else {
- mime = `image/${raw}`;
+ return null;
}
return `data:${mime};base64,${img.image_bytes}`;
},
@@ -6345,7 +6392,7 @@ export default {
},
async onStartCall() {
try {
- await window.api.get(`/api/v1/telephone/call/${this.selectedPeer.destination_hash}`);
+ await window.api.post(`/api/v1/telephone/call/${this.selectedPeer.destination_hash}`);
} catch (e) {
const message = e.response?.data?.message ?? "Failed to start call";
DialogUtils.alert(message);
diff --git a/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue b/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
index b41fc06e..0b76790a 100644
--- a/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNSHManagerPage.vue
@@ -331,7 +331,7 @@ export default {
allowed_hashes_text: "",
command: "",
config_path: "",
- no_auth: true,
+ no_auth: false,
},
isNarrowScreen: false,
mobileSessionsOpen: false,
diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js
index cff72244..73b25abb 100644
--- a/meshchatx/src/frontend/js/MicronParser.js
+++ b/meshchatx/src/frontend/js/MicronParser.js
@@ -105,12 +105,15 @@ export default class MicronParser extends BaseMicronParser {
.replace(/!important/g, "")
.replace(/\s+/g, "")
.trim();
- if (prop === "position" && (/\bfixed\b/.test(val) || /\bsticky\b/.test(val))) {
+ if (
+ prop === "position" &&
+ (/\bfixed\b/.test(val) || /\bsticky\b/.test(val) || /\babsolute\b/.test(val))
+ ) {
return false;
}
if (dangerousProps.includes(prop)) return false;
- if (prop === "width" && /100v[wh]/.test(val)) return false;
- if (prop === "height" && /100v[hw]/.test(val)) return false;
+ if (prop === "width" && (/100v[wh]/.test(val) || /^100%$/.test(val))) return false;
+ if (prop === "height" && (/100v[hw]/.test(val) || /^100%$/.test(val))) return false;
return true;
});
return safe.join("; ").trim();
diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.js
index ebc7aba1..70105ac9 100644
--- a/meshchatx/src/frontend/js/NomadPageRenderer.js
+++ b/meshchatx/src/frontend/js/NomadPageRenderer.js
@@ -85,13 +85,13 @@ export function stripOverlayFromCss(css) {
s = s.replace(/[\u00AD\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g, "");
s = s.replace(/position\s*:\s*[^;{}]+/gi, (decl) => {
const lower = decl.toLowerCase().replace(/\s+/g, "");
- if (/\bfixed\b/.test(lower) || /\bsticky\b/.test(lower)) {
+ if (/\bfixed\b/.test(lower) || /\bsticky\b/.test(lower) || /\babsolute\b/.test(lower)) {
return "position:static";
}
return decl;
});
s = s.replace(/\b(?:z-index|inset|top|left|right|bottom|transform)\s*:\s*[^;{}]+/gi, "");
- s = s.replace(/\b(?:width|height)\s*:\s*[^;{}]*100v[wh][^;{}]*/gi, "");
+ s = s.replace(/\b(?:width|height)\s*:\s*[^;{}]*100(?:v[wh]|%)[^;{}]*/gi, "");
return s;
}
@@ -213,7 +213,8 @@ function isAllowedImgSrc(src) {
return false;
}
const s = src.trim();
- return /^data:image\/(png|gif|jpeg|jpg|webp|svg\+xml)/i.test(s);
+ // Disallow svg+xml data URLs (scriptable / overlay-capable in some contexts).
+ return /^data:image\/(png|gif|jpeg|jpg|webp)(;|,)/i.test(s);
}
let nomadPurifyHooksInstalled = false;
diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
index 2d37978f..dcddf708 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
@@ -439,8 +439,7 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
if (typeof document !== "undefined" && canvas?.parentElement) {
labelCanvas = document.createElement("canvas");
labelCanvas.className = "network-webgl-labels";
- labelCanvas.style.cssText =
- "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;";
+ labelCanvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;";
canvas.parentElement.appendChild(labelCanvas);
labelCtx = labelCanvas.getContext("2d");
}
diff --git a/meshchatx/src/path_utils.py b/meshchatx/src/path_utils.py
index 055cc962..528e98bb 100644
--- a/meshchatx/src/path_utils.py
+++ b/meshchatx/src/path_utils.py
@@ -9,6 +9,26 @@ import tempfile
from aiohttp import web
+def safe_path_under_dir(directory: str, filename: str) -> str | None:
+ """Resolve filename as a basename under directory, or None if unsafe.
+
+ Rejects NUL, empty names, dot, dot-dot, and drive-letter basenames.
+ The result is realpath-checked so it cannot escape directory.
+ """
+ if not isinstance(directory, str) or not directory:
+ return None
+ if not isinstance(filename, str) or not filename or "\x00" in filename:
+ return None
+ base = os.path.basename(filename.replace("\\", "/"))
+ if not base or base in {".", ".."} or ":" in base:
+ return None
+ path = os.path.realpath(os.path.join(directory, base))
+ root = os.path.realpath(directory)
+ if path != root and not path.startswith(root + os.sep):
+ return None
+ return path
+
+
def resolve_log_dir():
"""Choose a writable log directory across container, desktop, and Windows."""
env_dir = os.environ.get("MESHCHAT_LOG_DIR")
diff --git a/scripts/argos_translate.py b/scripts/argos_translate.py
index 4dc2f4d6..7d308149 100755
--- a/scripts/argos_translate.py
+++ b/scripts/argos_translate.py
@@ -2,8 +2,8 @@
"""Argos Translate JSON localization script.
This script provides an automated workflow to translate JSON localization files
-(such as `en.json`) to target languages using Argos Translate. It ensures that
-interpolated variables (e.g., `{count}`, `{status}`) are preserved and not
+(such as en.json) to target languages using Argos Translate. It ensures that
+interpolated variables (e.g., {count}, {status}) are preserved and not
altered during the translation process.
Requirements:
@@ -66,7 +66,7 @@ except ImportError:
def ensure_package_installed(from_code, to_code):
- """Ensure the translation package from `from_code` to `to_code` is installed.
+ """Ensure the translation package from from_code to to_code is installed.
If not installed, attempts to download and install it automatically.
"""
@@ -123,7 +123,7 @@ def get_translation_func(from_code, to_code):
def replace_vars_with_tokens(text):
- """Replaces `{variable}` patterns with a standard token like `XVAR0X`.
+ """Replaces {variable} patterns with a standard token like XVAR0X.
So the translation engine doesn't attempt to translate variable names.
Returns the modified text and the list of found variables.
@@ -139,9 +139,9 @@ def replace_vars_with_tokens(text):
def restore_vars_from_tokens(text, vars_found):
- """Restores the original `{variable}` patterns back into the translated text.
+ """Restores the original {variable} patterns back into the translated text.
- Looks for the `XVAR0X` tokens.
+ Looks for the XVAR0X tokens.
"""
for i, var in enumerate(vars_found):
# The translation engine might change case or spacing around the token
@@ -152,7 +152,7 @@ def restore_vars_from_tokens(text, vars_found):
def translate_dict(data, translate_func, target_name=None):
"""Recursively iterates over a dictionary and translates all string values.
- Skips the `_languageName` key, which can be explicitly set.
+ Skips the _languageName key, which can be explicitly set.
"""
if isinstance(data, dict):
new_dict = {}
diff --git a/scripts/build/fetch_repository_wheels.py b/scripts/build/fetch_repository_wheels.py
index c5245259..80633141 100644
--- a/scripts/build/fetch_repository_wheels.py
+++ b/scripts/build/fetch_repository_wheels.py
@@ -3,7 +3,7 @@
Wheels are written to meshchatx/public/repository-server-bundled/bundled so they
ship with the same artifact layout as the Vite output. At runtime,
-:class:`~meshchatx.src.backend.repository_server_manager.RepositoryServerManager`
+RepositoryServerManager
copies any missing *.whl files from that directory into each identity's
repository-server/bundled folder (no network required).
diff --git a/scripts/build/fetch_reticulum_manual.py b/scripts/build/fetch_reticulum_manual.py
index 015c7635..09611c4e 100755
--- a/scripts/build/fetch_reticulum_manual.py
+++ b/scripts/build/fetch_reticulum_manual.py
@@ -118,7 +118,7 @@ def _extract(
"""Extract docs/ tree from archive into dest.
Returns (extracted_count, skipped_binary_count). When include_pdf is
- false, large alternate-format manuals listed in :data:`EXTRA_BINARY_SUFFIXES`
+ false, large alternate-format manuals listed in EXTRA_BINARY_SUFFIXES
are skipped to keep shipped artifacts small.
"""
extracted = 0
diff --git a/scripts/build_community_interfaces_json.py b/scripts/build_community_interfaces_json.py
index fa87ad50..13362174 100644
--- a/scripts/build_community_interfaces_json.py
+++ b/scripts/build_community_interfaces_json.py
@@ -26,7 +26,7 @@ def main() -> int:
"source",
nargs="?",
default=None,
- help=f"Local JSON (directory `data` shape). Default: fetch {DEFAULT_SUBMITTED_URL}",
+ help=f"Local JSON (directory data shape). Default: fetch {DEFAULT_SUBMITTED_URL}",
)
parser.add_argument(
"--url",
diff --git a/scripts/deps-allowlist.json b/scripts/deps-allowlist.json
index b0328868..62537509 100644
--- a/scripts/deps-allowlist.json
+++ b/scripts/deps-allowlist.json
@@ -1,35 +1,35 @@
{
- "version": 1,
- "git_dependencies": [
- {
- "name": "micron-parser",
- "specifiers": [
- "github:RFnexus/micron-parser-js",
- "github:RFnexus/micron-parser-js#33feb1054c8b2cb3f5f05abbb8903360d3f0c098"
- ],
- "commit": "33feb1054c8b2cb3f5f05abbb8903360d3f0c098",
- "repository": "https://github.com/RFnexus/micron-parser-js"
- }
- ],
- "git_sources": [
- {
- "name": "zypak",
- "url": "https://github.com/refi64/zypak",
- "tag": "v2025.09",
- "commit": "693a71c5ffa80ec9c9ce2ae03b1ccc493c698e53",
- "declared_in": "package.json#build.flatpak.modules"
- }
- ],
- "download_urls": [
- {
- "name": "micron-parser-go-wasm",
- "pattern": "https://github.com/Quad4-Software/Micron-Parser-Go/releases/download/*/micron-parser-go.wasm",
- "pinned_tag": "v1.0.5",
- "tag_source": "scripts/micron-parser-go-version.mjs"
- },
- {
- "name": "golang-wasm-exec",
- "url": "https://raw.githubusercontent.com/golang/go/go1.26.2/lib/wasm/wasm_exec.js"
- }
- ]
+ "version": 1,
+ "git_dependencies": [
+ {
+ "name": "micron-parser",
+ "specifiers": [
+ "github:RFnexus/micron-parser-js",
+ "github:RFnexus/micron-parser-js#33feb1054c8b2cb3f5f05abbb8903360d3f0c098"
+ ],
+ "commit": "33feb1054c8b2cb3f5f05abbb8903360d3f0c098",
+ "repository": "https://github.com/RFnexus/micron-parser-js"
+ }
+ ],
+ "git_sources": [
+ {
+ "name": "zypak",
+ "url": "https://github.com/refi64/zypak",
+ "tag": "v2025.09",
+ "commit": "693a71c5ffa80ec9c9ce2ae03b1ccc493c698e53",
+ "declared_in": "package.json#build.flatpak.modules"
+ }
+ ],
+ "download_urls": [
+ {
+ "name": "micron-parser-go-wasm",
+ "pattern": "https://github.com/Quad4-Software/Micron-Parser-Go/releases/download/*/micron-parser-go.wasm",
+ "pinned_tag": "v1.0.5",
+ "tag_source": "scripts/micron-parser-go-version.mjs"
+ },
+ {
+ "name": "golang-wasm-exec",
+ "url": "https://raw.githubusercontent.com/golang/go/go1.26.2/lib/wasm/wasm_exec.js"
+ }
+ ]
}
diff --git a/tests/backend/eect/catalog.py b/tests/backend/eect/catalog.py
index 8e7711ac..1184b34d 100644
--- a/tests/backend/eect/catalog.py
+++ b/tests/backend/eect/catalog.py
@@ -90,7 +90,21 @@ SCENARIOS: tuple[Scenario, ...] = (
pack="HostileMediumPack",
gate="gate3-hostile-medium",
taxonomy="security_surface",
- summary="LibreTranslate URL guard rejects decimal/hex link-local SSRF forms",
+ summary="LibreTranslate URL guard rejects decimal/hex/IPv6-mapped link-local SSRF forms",
+ ),
+ Scenario(
+ id="hostile.plugin.path_escape",
+ pack="HostileMediumPack",
+ gate="gate3-hostile-medium",
+ taxonomy="security_surface",
+ summary="plugin asset/zip paths reject drive letters, NULs, and traversal",
+ ),
+ Scenario(
+ id="hostile.overlay.path_escape",
+ pack="HostileMediumPack",
+ gate="gate3-hostile-medium",
+ taxonomy="security_surface",
+ summary="map overlay relpaths reject drive letters, NULs, and traversal",
),
Scenario(
id="scarcity.conversation.preview_capped",
diff --git a/tests/backend/eect/packs/test_hostile_medium_pack.py b/tests/backend/eect/packs/test_hostile_medium_pack.py
index 2acaec31..165e61e7 100644
--- a/tests/backend/eect/packs/test_hostile_medium_pack.py
+++ b/tests/backend/eect/packs/test_hostile_medium_pack.py
@@ -150,6 +150,38 @@ def test_eect_rejects_decimal_hex_link_local_urls():
"http://2852039166/",
"http://0xa9fea9fe/",
"http://169.254.169.254/",
+ "http://[::ffff:169.254.169.254]/",
+ "http://[::ffff:a9fe:a9fe]/",
+ "http://[fe80::1]/",
):
with pytest.raises(UnsafeOutboundUrlError):
normalize_libretranslate_http_service_base(bad)
+
+
+def test_eect_plugin_paths_reject_escape_forms():
+ from meshchatx.src.backend.plugin_guard import (
+ PluginSecurityError,
+ _zip_entry_is_safe,
+ normalize_asset_path,
+ )
+
+ with eect_scenario("hostile.plugin.path_escape") as (_s, _seed, _rng):
+ for bad in ("../x", "/etc/passwd", "C:/Windows/x", "a/\x00/b"):
+ with pytest.raises(PluginSecurityError):
+ normalize_asset_path(bad)
+ assert _zip_entry_is_safe("ok.wasm") is True
+ assert _zip_entry_is_safe("C:/x") is False
+ assert _zip_entry_is_safe("../x") is False
+
+
+def test_eect_overlay_paths_reject_escape_forms():
+ from meshchatx.src.backend.map_overlay_sources import (
+ OverlaySourceParseError,
+ _safe_repo_relpath,
+ )
+
+ with eect_scenario("hostile.overlay.path_escape") as (_s, _seed, _rng):
+ assert _safe_repo_relpath("layers/a.geojson") == "layers/a.geojson"
+ for bad in ("../x", "/x", "C:/Windows/x", "a\x00b", "~/.ssh/id"):
+ with pytest.raises(OverlaySourceParseError):
+ _safe_repo_relpath(bad)
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index 30ffdcdb..632932d4 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -1,1564 +1,1564 @@
{
- "routes": [
- {
- "method": "GET",
- "path": "/"
- },
- {
- "method": "GET",
- "path": "/api/v1/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/announces"
- },
- {
- "method": "POST",
- "path": "/api/v1/announces/query"
- },
- {
- "method": "GET",
- "path": "/api/v1/app/changelog"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/changelog/seen"
- },
- {
- "method": "GET",
- "path": "/api/v1/app/info"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/integrity/acknowledge"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/shutdown"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/tutorial/seen"
- },
- {
- "method": "GET",
- "path": "/api/v1/auth/csrf"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/login"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/logout"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/setup"
- },
- {
- "method": "GET",
- "path": "/api/v1/auth/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/blocked-destinations"
- },
- {
- "method": "POST",
- "path": "/api/v1/blocked-destinations"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/blocked-destinations/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/announce"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/delete"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/restart"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/start"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/subprocess-log"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/bots/update"
- },
- {
- "method": "GET",
- "path": "/api/v1/community-interfaces"
- },
- {
- "method": "POST",
- "path": "/api/v1/community-interfaces/refresh"
- },
- {
- "method": "GET",
- "path": "/api/v1/comports"
- },
- {
- "method": "GET",
- "path": "/api/v1/config"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/config"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/backup"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backup/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backups"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/database/backups/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backups/{filename}/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/health"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/recover"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/restore"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/snapshot"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/snapshots"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/database/snapshots/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/snapshots/{filename}/download"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/vacuum"
- },
- {
- "method": "GET",
- "path": "/api/v1/debug/access-attempts"
- },
- {
- "method": "GET",
- "path": "/api/v1/debug/logs"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/custom-display-name"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/custom-display-name/update"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/drop-path"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/lxmf-stamp-info"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/path"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/request-path"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/signal-metrics"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/gc"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/gc/collect"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/heap"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/referrers"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/reset"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/snapshot"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/export"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/export/reticulum"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/search"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/docs/switch"
- },
- {
- "method": "POST",
- "path": "/api/v1/docs/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/docs/version/{version}"
- },
- {
- "method": "GET",
- "path": "/api/v1/favourites"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/add"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/import"
- },
- {
- "method": "GET",
- "path": "/api/v1/favourites/layout"
- },
- {
- "method": "PUT",
- "path": "/api/v1/favourites/layout"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/favourites/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/{destination_hash}/rename"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/gifs/{gif_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/gifs/{gif_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs/{gif_id}/image"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs/{gif_id}/use"
- },
- {
- "method": "GET",
- "path": "/api/v1/identities"
- },
- {
- "method": "POST",
- "path": "/api/v1/identities/create"
- },
- {
- "method": "GET",
- "path": "/api/v1/identities/export-all"
- },
- {
- "method": "POST",
- "path": "/api/v1/identities/switch"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/identities/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/identity/backup/base32"
- },
- {
- "method": "GET",
- "path": "/api/v1/identity/backup/download"
- },
- {
- "method": "POST",
- "path": "/api/v1/identity/restore"
- },
- {
- "method": "GET",
- "path": "/api/v1/interface-stats"
- },
- {
- "method": "GET",
- "path": "/api/v1/licenses"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/reactions"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/send"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf-messages/{hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/{hash}/cancel"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/{hash}/spam"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/{message_hash}/uri"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/conversation-pins"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversation-pins/toggle"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/conversations"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/bulk-delete"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/bulk-mark-as-read"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/move-to-folder"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/{destination_hash}/mark-as-read"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/folders"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/folders"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/folders/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/folders/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf/folders/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/lxmf/folders/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/message-blocklist"
- },
- {
- "method": "PUT",
- "path": "/api/v1/lxmf/message-blocklist"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/message-blocklist/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/message-blocklist/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/propagation-node/restart"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/propagation-node/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/stop-sync"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/sync"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-nodes"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/sieve-filters"
- },
- {
- "method": "PUT",
- "path": "/api/v1/lxmf/sieve-filters"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/announces"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/archives"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/docs/reticulum"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/favourites"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/gifs"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/lxmf-icons"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/messages"
- },
- {
- "method": "GET",
- "path": "/api/v1/maintenance/messages/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/maintenance/messages/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/maintenance/messages/import-file"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/path-table"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/stickers"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/drawings"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/drawings"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/drawings/{drawing_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/map/drawings/{drawing_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/export"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/export/{export_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/export/{export_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/export/{export_id}/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/mbtiles"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/mbtiles/active"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/mbtiles/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/offline"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/offline"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/overlays"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/overlays"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/overlays/export"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/overlays/jobs/{job_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/overlays/jobs/{job_id}/cancel"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/overlays/{overlay_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/map/overlays/{overlay_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/overlays/{overlay_id}/content"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/overlays/{overlay_id}/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/overlays/{overlay_id}/refresh"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/tiles/{z}/{x}/{y}"
- },
- {
- "method": "GET",
- "path": "/api/v1/meshchatx-docs/content"
- },
- {
- "method": "GET",
- "path": "/api/v1/meshchatx-docs/list"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/nomadnet/archives"
- },
- {
- "method": "GET",
- "path": "/api/v1/nomadnet/archives"
- },
- {
- "method": "POST",
- "path": "/api/v1/nomadnetwork/{destination_hash}/identify"
- },
- {
- "method": "GET",
- "path": "/api/v1/notification-sounds"
- },
- {
- "method": "GET",
- "path": "/api/v1/notification-sounds/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/notification-sounds/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/notification-sounds/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/notification-sounds/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/notification-sounds/{id}/audio"
- },
- {
- "method": "GET",
- "path": "/api/v1/notifications"
- },
- {
- "method": "POST",
- "path": "/api/v1/notifications/mark-as-viewed"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/files"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/files"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}/files/{file_name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/pages"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/pages"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
- },
- {
- "method": "PUT",
- "path": "/api/v1/page-nodes/{node_id}/rename"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/path-table"
- },
- {
- "method": "POST",
- "path": "/api/v1/path-table"
- },
- {
- "method": "GET",
- "path": "/api/v1/ping/{destination_hash}/lxmf.delivery"
- },
- {
- "method": "GET",
- "path": "/api/v1/plugins"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/install"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/preview"
- },
- {
- "method": "GET",
- "path": "/api/v1/plugins/trusted-publishers"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/trusted-publishers"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/plugins/trusted-publishers/{identity}"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/plugins/{plugin_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/disable"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/enable"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/invoke"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/report-failure"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/restart"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/repository-server/list"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/refresh-bundled"
- },
- {
- "method": "GET",
- "path": "/api/v1/repository-server/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/repository-server/upload/{name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/blackhole"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/config/raw"
- },
- {
- "method": "PUT",
- "path": "/api/v1/reticulum/config/raw"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/config/reset"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/disable-transport"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/discovered-interfaces"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/discovery"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/reticulum/discovery"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/enable-transport"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/instance"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/reticulum/instance"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/interface-modules"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interface-modules"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/reticulum/interface-modules/{type_name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/interfaces"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/add"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/bitrates"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/delete"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/disable"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/enable"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/import-preview"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/management-identities"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/management-identities"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/recover"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/reload"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/cancel"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/fetch"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/listen"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/send"
- },
- {
- "method": "GET",
- "path": "/api/v1/rncp/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/rncp/transfer/{transfer_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop-queues"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop-via"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/rates"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/request"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/table"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/trace/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnprobe"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnsh/sessions"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rnsh/sessions/{session_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/clear"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/input"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnsh/sessions/{session_id}/output"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/resize"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnstatus"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnx/sessions"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rnx/sessions/{session_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions/{session_id}/clear"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions/{session_id}/input"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnx/sessions/{session_id}/output"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions/{session_id}/resize"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions/{session_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions/{session_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/hubs"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs"
- },
- {
- "method": "PUT",
- "path": "/api/v1/rrc/hubs/order"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/rrc/hubs/{hub_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/command"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/connect"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/disconnect"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms"
- },
- {
- "method": "PUT",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/order"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/read"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/servers/{hub_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/rrc/servers/{hub_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/activity"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/members"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/moderate"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/rooms"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/servers/{hub_id}/rooms/{room}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/self-test"
- },
- {
- "method": "GET",
- "path": "/api/v1/server/security"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/server/security"
- },
- {
- "method": "POST",
- "path": "/api/v1/setup/storage-migration"
- },
- {
- "method": "GET",
- "path": "/api/v1/sideband-plugins"
- },
- {
- "method": "GET",
- "path": "/api/v1/sideband-plugins/config"
- },
- {
- "method": "POST",
- "path": "/api/v1/sideband-plugins/config"
- },
- {
- "method": "POST",
- "path": "/api/v1/sideband-plugins/reload"
- },
- {
- "method": "GET",
- "path": "/api/v1/spam-keywords"
- },
- {
- "method": "POST",
- "path": "/api/v1/spam-keywords"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/spam-keywords/{keyword_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs/install"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs/reorder"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs/{pack_id}/export"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers"
- },
- {
- "method": "POST",
- "path": "/api/v1/stickers"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/stickers/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/stickers/{sticker_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/stickers/{sticker_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers/{sticker_id}/image"
- },
- {
- "method": "GET",
- "path": "/api/v1/system/network-interfaces"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/history/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/latest/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/peers"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/tracking"
- },
- {
- "method": "POST",
- "path": "/api/v1/telemetry/tracking/{destination_hash}/toggle"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/trusted-peers"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/answer"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/audio-profiles"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/call/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/codec2/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/contacts"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts/check/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/contacts/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/contacts/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/telephone/contacts/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/hangup"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/history"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/history"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/missed-calls/mark-viewed"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/mute-receive"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/mute-transmit"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/recordings"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/recordings/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/recordings/{id}/audio/{side}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/ringtones/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/ringtones/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/telephone/ringtones/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones/{id}/audio"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/send-to-voicemail"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/switch-audio-profile/{profile_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/unmute-receive"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/unmute-transmit"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/generate-greeting"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/voicemail/greeting"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemail/greeting/audio"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/record/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/record/stop"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/upload"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemail/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemails"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/voicemails/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemails/{id}/audio"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemails/{id}/read"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/micron-parser-go-release"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/rnode/download_firmware"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/rnode/latest_release"
- },
- {
- "method": "POST",
- "path": "/api/v1/translator/install-languages"
- },
- {
- "method": "GET",
- "path": "/api/v1/translator/languages"
- },
- {
- "method": "POST",
- "path": "/api/v1/translator/translate"
- },
- {
- "method": "GET",
- "path": "/call.html"
- },
- {
- "method": "GET",
- "path": "/manifest.json"
- },
- {
- "method": "GET",
- "path": "/service-worker.js"
- },
- {
- "method": "GET",
- "path": "/ws"
- },
- {
- "method": "GET",
- "path": "/ws/telephone/audio"
- }
- ]
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/announces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/announces/query"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/app/changelog"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/changelog/seen"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/app/info"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/integrity/acknowledge"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/shutdown"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/tutorial/seen"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/auth/csrf"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/login"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/logout"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/setup"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/auth/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/blocked-destinations"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/blocked-destinations"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/blocked-destinations/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/announce"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/delete"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/restart"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/start"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/subprocess-log"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/bots/update"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/community-interfaces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/community-interfaces/refresh"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/comports"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/config"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/config"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/backup"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backup/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backups"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/database/backups/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backups/{filename}/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/health"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/recover"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/restore"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/snapshot"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/snapshots"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/database/snapshots/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/snapshots/{filename}/download"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/vacuum"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/debug/access-attempts"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/debug/logs"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/custom-display-name"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/custom-display-name/update"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/drop-path"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/lxmf-stamp-info"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/path"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/request-path"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/signal-metrics"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/gc"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/gc/collect"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/heap"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/referrers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/reset"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/snapshot"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/export/reticulum"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/search"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/docs/switch"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/docs/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/docs/version/{version}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/favourites"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/add"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/import"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/favourites/layout"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/favourites/layout"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/favourites/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/{destination_hash}/rename"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/gifs/{gif_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/gifs/{gif_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs/{gif_id}/image"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs/{gif_id}/use"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identities"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identities/create"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identities/export-all"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identities/switch"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/identities/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identity/backup/base32"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identity/backup/download"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identity/restore"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/interface-stats"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/licenses"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/reactions"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/send"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf-messages/{hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/{hash}/cancel"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/{hash}/spam"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/{message_hash}/uri"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/conversation-pins"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversation-pins/toggle"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/conversations"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/bulk-delete"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/bulk-mark-as-read"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/move-to-folder"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/{destination_hash}/mark-as-read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/folders"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/folders"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/folders/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/folders/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf/folders/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/lxmf/folders/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/message-blocklist"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/lxmf/message-blocklist"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/message-blocklist/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/message-blocklist/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/propagation-node/restart"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/propagation-node/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/stop-sync"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/sync"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-nodes"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/sieve-filters"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/lxmf/sieve-filters"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/announces"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/archives"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/docs/reticulum"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/favourites"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/gifs"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/lxmf-icons"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/messages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/maintenance/messages/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/maintenance/messages/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/maintenance/messages/import-file"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/path-table"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/stickers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/drawings"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/drawings"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/drawings/{drawing_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/map/drawings/{drawing_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/export"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/export/{export_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/export/{export_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/export/{export_id}/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/mbtiles"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/mbtiles/active"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/mbtiles/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/offline"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/offline"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays/jobs/{job_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays/jobs/{job_id}/cancel"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/overlays/{overlay_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/map/overlays/{overlay_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays/{overlay_id}/content"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays/{overlay_id}/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays/{overlay_id}/refresh"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/tiles/{z}/{x}/{y}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/meshchatx-docs/content"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/meshchatx-docs/list"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/nomadnet/archives"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/nomadnet/archives"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/nomadnetwork/{destination_hash}/identify"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/notification-sounds"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/notification-sounds/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/notification-sounds/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/notification-sounds/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/notification-sounds/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/notification-sounds/{id}/audio"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/notifications"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/notifications/mark-as-viewed"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/files"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/files"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}/files/{file_name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/pages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/pages"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/page-nodes/{node_id}/rename"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/path-table"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/path-table"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/ping/{destination_hash}/lxmf.delivery"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/plugins"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/install"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/preview"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/plugins/trusted-publishers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/trusted-publishers"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/plugins/trusted-publishers/{identity}"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/plugins/{plugin_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/disable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/enable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/invoke"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/report-failure"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/restart"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/repository-server/list"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/refresh-bundled"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/repository-server/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/repository-server/upload/{name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/blackhole"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/config/raw"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/reticulum/config/raw"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/config/reset"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/disable-transport"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/discovered-interfaces"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/discovery"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/reticulum/discovery"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/enable-transport"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/instance"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/reticulum/instance"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/interface-modules"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interface-modules"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/reticulum/interface-modules/{type_name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/interfaces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/add"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/bitrates"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/delete"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/disable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/enable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/import-preview"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/management-identities"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/management-identities"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/recover"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/reload"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/cancel"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/fetch"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/listen"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/send"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rncp/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rncp/transfer/{transfer_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop-queues"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop-via"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/rates"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/request"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/table"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/trace/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnprobe"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnsh/sessions"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rnsh/sessions/{session_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/clear"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/input"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnsh/sessions/{session_id}/output"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/resize"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnstatus"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnx/sessions"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rnx/sessions/{session_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions/{session_id}/clear"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions/{session_id}/input"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnx/sessions/{session_id}/output"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions/{session_id}/resize"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions/{session_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions/{session_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/hubs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/rrc/hubs/order"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/rrc/hubs/{hub_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/command"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/connect"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/disconnect"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/order"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/servers/{hub_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/rrc/servers/{hub_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/activity"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/members"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/moderate"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/rooms"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/servers/{hub_id}/rooms/{room}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/self-test"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/server/security"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/server/security"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/setup/storage-migration"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sideband-plugins"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sideband-plugins/config"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sideband-plugins/config"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sideband-plugins/reload"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/spam-keywords"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/spam-keywords"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/spam-keywords/{keyword_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs/install"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs/reorder"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs/{pack_id}/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/stickers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/stickers/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/stickers/{sticker_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/stickers/{sticker_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers/{sticker_id}/image"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/system/network-interfaces"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/history/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/latest/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/peers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/tracking"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telemetry/tracking/{destination_hash}/toggle"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/trusted-peers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/answer"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/audio-profiles"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/call/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/codec2/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/contacts"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts/check/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/contacts/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/contacts/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/telephone/contacts/{id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/hangup"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/history"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/history"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/missed-calls/mark-viewed"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/mute-receive"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/mute-transmit"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/recordings"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/recordings/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/recordings/{id}/audio/{side}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/ringtones/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/ringtones/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/telephone/ringtones/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones/{id}/audio"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/send-to-voicemail"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/switch-audio-profile/{profile_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/unmute-receive"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/unmute-transmit"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/generate-greeting"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/voicemail/greeting"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemail/greeting/audio"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/record/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/record/stop"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/upload"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemail/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemails"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/voicemails/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemails/{id}/audio"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemails/{id}/read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/micron-parser-go-release"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/rnode/download_firmware"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/rnode/latest_release"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/translator/install-languages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/translator/languages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/translator/translate"
+ },
+ {
+ "method": "GET",
+ "path": "/call.html"
+ },
+ {
+ "method": "GET",
+ "path": "/manifest.json"
+ },
+ {
+ "method": "GET",
+ "path": "/service-worker.js"
+ },
+ {
+ "method": "GET",
+ "path": "/ws"
+ },
+ {
+ "method": "GET",
+ "path": "/ws/telephone/audio"
+ }
+ ]
}
diff --git a/tests/backend/http_api_response_registry.py b/tests/backend/http_api_response_registry.py
index 7be54859..c0945288 100644
--- a/tests/backend/http_api_response_registry.py
+++ b/tests/backend/http_api_response_registry.py
@@ -107,7 +107,6 @@ from tests.backend.http_api_response_schemas import (
TELEMETRY_TRACKING_SCHEMA,
TELEMETRY_TRUSTED_PEERS_SCHEMA,
TELEPHONE_AUDIO_PROFILES_SCHEMA,
- TELEPHONE_CALL_SCHEMA,
TELEPHONE_CODEC2_STATUS_SCHEMA,
TELEPHONE_HISTORY_SCHEMA,
TELEPHONE_RECORDINGS_SCHEMA,
@@ -497,12 +496,6 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
"/api/v1/telephone/codec2/status",
TELEPHONE_CODEC2_STATUS_SCHEMA,
),
- HttpJsonContract(
- "GET",
- "/api/v1/telephone/call/{identity_hash}",
- TELEPHONE_CALL_SCHEMA,
- match_info={"identity_hash": _HEX32},
- ),
HttpJsonContract(
"GET",
"/api/v1/telephone/voicemail/status",
@@ -579,14 +572,6 @@ HTTP_JSON_GET_CONTRACT_EXCLUDED: tuple[str, ...] = (
"/api/v1/telephone/ringtones/{id}/audio",
"/api/v1/telephone/voicemail/greeting/audio",
"/api/v1/telephone/voicemails/{id}/audio",
- "/api/v1/telephone/answer",
- "/api/v1/telephone/hangup",
- "/api/v1/telephone/send-to-voicemail",
- "/api/v1/telephone/mute-transmit",
- "/api/v1/telephone/mute-receive",
- "/api/v1/telephone/unmute-transmit",
- "/api/v1/telephone/unmute-receive",
- "/api/v1/telephone/switch-audio-profile/{profile_id}",
"/api/v1/lxmf/propagation-node/sync",
"/api/v1/lxmf/propagation-node/stop-sync",
"/api/v1/ping/{destination_hash}/lxmf.delivery",
diff --git a/tests/backend/test_announces_search_pagination.py b/tests/backend/test_announces_search_pagination.py
index cb43ab86..257d3ebf 100644
--- a/tests/backend/test_announces_search_pagination.py
+++ b/tests/backend/test_announces_search_pagination.py
@@ -2,11 +2,11 @@
"""Regression test: announce search must scan beyond the requested page size.
-Previously the `/api/v1/announces` search path capped the number of rows
+Previously the /api/v1/announces search path capped the number of rows
fetched from the database to the caller's requested page size (e.g. 50,
used for pagination of already-filtered results) instead of the configured
search scan limit. This meant a match that was not among the most
-recently-updated `limit` announces for the aspect would never be found by
+recently-updated limit announces for the aspect would never be found by
search, even though it had briefly been visible client-side because it was
already loaded from an earlier, non-search page.
"""
@@ -74,7 +74,7 @@ async def test_announce_search_finds_match_older_than_page_size(
# Seed more announces than the page size, with the searched-for node
# being the *oldest* by updated_at so it would be excluded if the
- # search scan were (incorrectly) capped to `page_size` rows.
+ # search scan were (incorrectly) capped to page_size rows.
total_announces = page_size + 10
with db.provider:
for i in range(total_announces):
@@ -98,8 +98,8 @@ async def test_announce_search_finds_match_older_than_page_size(
(f"2020-01-01T00:{i:02d}:00Z", dest_hash),
)
- # `RNS.Transport` is patched to a MagicMock by the fixture above, so
- # give `hops_to` a JSON-serialisable return value.
+ # RNS.Transport is patched to a MagicMock by the fixture above, so
+ # give hops_to a JSON-serialisable return value.
RNS.Transport.hops_to.return_value = 1
request = MagicMock()
diff --git a/tests/backend/test_archives_api_robustness.py b/tests/backend/test_archives_api_robustness.py
index 61f8ca7e..95bfdd4f 100644
--- a/tests/backend/test_archives_api_robustness.py
+++ b/tests/backend/test_archives_api_robustness.py
@@ -190,3 +190,17 @@ async def test_nomadnet_file_download_invalid_hex_does_not_raise(mock_app):
},
},
)
+
+
+@pytest.mark.asyncio
+async def test_nomadnet_page_download_invalid_hex_does_not_raise(mock_app):
+ await mock_app.on_websocket_data_received(
+ MagicMock(),
+ {
+ "type": "nomadnet.page.download",
+ "nomadnet_page_download": {
+ "destination_hash": "not-hex",
+ "page_path": "/page/index.mu",
+ },
+ },
+ )
diff --git a/tests/backend/test_http_url_guard.py b/tests/backend/test_http_url_guard.py
index 4ceded14..295a3f90 100644
--- a/tests/backend/test_http_url_guard.py
+++ b/tests/backend/test_http_url_guard.py
@@ -107,6 +107,10 @@ def test_normalize_libretranslate_private_and_loopback_ips():
"http://2852039166/",
"http://0xa9fea9fe/",
"http://0xA9FEA9FE:80/",
+ # IPv4-mapped IPv6 link-local / metadata forms.
+ "http://[::ffff:169.254.169.254]/",
+ "http://[::ffff:a9fe:a9fe]/",
+ "http://[fe80::1]/",
],
)
def test_normalize_libretranslate_rejects_ssrf_lit_ips(bad):
diff --git a/tests/backend/test_log_redaction.py b/tests/backend/test_log_redaction.py
index 5d58c1d3..adac2071 100644
--- a/tests/backend/test_log_redaction.py
+++ b/tests/backend/test_log_redaction.py
@@ -28,11 +28,13 @@ def test_redact_pem_bearer_and_secret_assigns():
pem = "-----BEGIN PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END PRIVATE KEY-----"
out = redact_diagnostic_text(
f"{pem} Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaa.bbb "
- "alias_identity_private_key=YWJjZGVm",
+ "alias_identity_private_key=YWJjZGVm "
+ "Authorization: Basic dXNlcjpwYXNz",
)
assert "BEGIN PRIVATE KEY" not in out
assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in out
assert "YWJjZGVm" not in out
+ assert "dXNlcjpwYXNz" not in out
assert "Bearer" in out
assert REDACTED in out
diff --git a/tests/backend/test_lxmf_image_magic.py b/tests/backend/test_lxmf_image_magic.py
new file mode 100644
index 00000000..c8e64269
--- /dev/null
+++ b/tests/backend/test_lxmf_image_magic.py
@@ -0,0 +1,35 @@
+# SPDX-License-Identifier: 0BSD
+
+"""LXMF attachment image serving must trust magic bytes, not declared type."""
+
+import base64
+
+from meshchatx.src.backend.sticker_utils import detect_image_format_from_magic
+
+
+_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
+_ALLOWED = {"png", "jpeg", "jpg", "gif", "webp", "bmp"}
+
+
+def _resolve_served_image_type(image_data: bytes) -> str | None:
+ detected = detect_image_format_from_magic(image_data)
+ if detected is None or detected not in _ALLOWED:
+ return None
+ return "jpeg" if detected == "jpeg" else detected
+
+
+def test_lxmf_image_serve_uses_png_magic_not_declared_html():
+ assert _resolve_served_image_type(_PNG) == "png"
+ assert _resolve_served_image_type(b"<html><script>x</script>") is None
+ assert _resolve_served_image_type(b"") is None
+
+
+def test_lxmf_image_send_rejects_non_image_payload():
+ raw = b"not-an-image"
+ detected = detect_image_format_from_magic(raw)
+ assert detected is None or detected in {"webm", "tgs"}
+
+
+def test_png_bytes_survive_b64_roundtrip_detection():
+ b64 = base64.b64encode(_PNG).decode("ascii")
+ assert detect_image_format_from_magic(base64.b64decode(b64)) == "png"
diff --git a/tests/backend/test_map_overlay_sources.py b/tests/backend/test_map_overlay_sources.py
index 43bbb1bc..14205cf8 100644
--- a/tests/backend/test_map_overlay_sources.py
+++ b/tests/backend/test_map_overlay_sources.py
@@ -101,3 +101,18 @@ def test_parse_create_payload_rejects_bad_ref():
"paths": ["a.geojson"],
},
)
+
+
+def test_repo_relpath_rejects_drive_and_nul():
+ from meshchatx.src.backend.map_overlay_sources import _safe_repo_relpath
+
+ with pytest.raises(OverlaySourceParseError) as exc:
+ _safe_repo_relpath("C:/Windows/x")
+ assert exc.value.code == "invalid_path"
+ with pytest.raises(OverlaySourceParseError):
+ _safe_repo_relpath("a\x00b")
+
+
+def test_nomadnet_path_rejects_nul():
+ with pytest.raises(OverlaySourceParseError):
+ parse_nomadnet_file_url(f"{HASH}:/file/a\x00b.geojson")
diff --git a/tests/backend/test_notification_user_facing_filter.py b/tests/backend/test_notification_user_facing_filter.py
index 0e0373e0..6d94d691 100644
--- a/tests/backend/test_notification_user_facing_filter.py
+++ b/tests/backend/test_notification_user_facing_filter.py
@@ -3,12 +3,12 @@
"""Notification bell must never raise on silent payloads.
Covers:
- - the pure helper :func:`is_user_facing_lxmf_payload`
+ - the pure helper is_user_facing_lxmf_payload
- the conversation-row helper
- :func:`compute_lxmf_conversation_unread_from_latest_row` with
+ compute_lxmf_conversation_unread_from_latest_row with
require_user_facing=True
- the DAO method
- :func:`MessageDAO.get_latest_user_facing_incoming_message`
+ MessageDAO.get_latest_user_facing_incoming_message
- end-to-end GET /api/v1/notifications integration: reactions,
generic telemetry-only payloads, icon-only, empty pings and
delivery-status updates must not produce false unread badges or empty
diff --git a/tests/backend/test_performance_bottlenecks.py b/tests/backend/test_performance_bottlenecks.py
index bee75b8f..91c27a33 100644
--- a/tests/backend/test_performance_bottlenecks.py
+++ b/tests/backend/test_performance_bottlenecks.py
@@ -2,8 +2,8 @@
"""Wall-clock database throughput tests (large seeds + strict ms ceilings).
-Included in the full backend suite (`task test:be`, GitHub CI). For perf-only:
-`task test:be:perf` or `pytest tests/backend/test_performance_bottlenecks.py`.
+Included in the full backend suite (task test:be, GitHub CI). For perf-only:
+task test:be:perf or pytest tests/backend/test_performance_bottlenecks.py.
"""
import os
diff --git a/tests/backend/test_performance_hotpaths.py b/tests/backend/test_performance_hotpaths.py
index 24f527ab..ac4341c3 100644
--- a/tests/backend/test_performance_hotpaths.py
+++ b/tests/backend/test_performance_hotpaths.py
@@ -2,8 +2,8 @@
"""Performance regression tests for the critical hot paths.
-Run as part of the full backend suite (`task test:be`, `make test`, GitHub CI).
-For perf-only: `task test:be:perf`.
+Run as part of the full backend suite (task test:be, make test, GitHub CI).
+For perf-only: task test:be:perf.
Focus areas (user priority):
- NomadNet browser: load announces, search announces, favourites
diff --git a/tests/backend/test_plugin_security.py b/tests/backend/test_plugin_security.py
index e6c27f30..d14c647b 100644
--- a/tests/backend/test_plugin_security.py
+++ b/tests/backend/test_plugin_security.py
@@ -68,6 +68,17 @@ class TestPluginGuard:
normalize_asset_path("../plugin.json")
with pytest.raises(PluginSecurityError):
normalize_asset_path("/etc/passwd")
+ with pytest.raises(PluginSecurityError):
+ normalize_asset_path("C:/Windows/x")
+ with pytest.raises(PluginSecurityError):
+ normalize_asset_path("foo/\x00/bar")
+
+ def test_zip_entry_rejects_windows_drive_and_nul(self):
+ from meshchatx.src.backend.plugin_guard import _zip_entry_is_safe
+
+ assert _zip_entry_is_safe("ok/a.wasm") is True
+ assert _zip_entry_is_safe("C:/Windows/x") is False
+ assert _zip_entry_is_safe("a/\x00/b") is False
def test_validate_zip_bytes_rejects_empty_and_random_payload(self):
with pytest.raises(PluginSecurityError):
diff --git a/tests/backend/test_ringtone_manager.py b/tests/backend/test_ringtone_manager.py
index d052d6b2..972958df 100644
--- a/tests/backend/test_ringtone_manager.py
+++ b/tests/backend/test_ringtone_manager.py
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: 0BSD
-"""Tests for :mod:`meshchatx.src.backend.ringtone_manager`.
+"""Tests for meshchatx.src.backend.ringtone_manager.
The manager performs conversion in-process via audio_codec
(miniaudio + LXST). These tests pin the contract that
@@ -84,3 +84,17 @@ def test_remove_ringtone_deletes_file(manager, tmp_path):
def test_remove_ringtone_missing_returns_true(manager):
assert manager.remove_ringtone("does-not-exist.opus") is True
+
+
+def test_get_ringtone_path_rejects_traversal(manager):
+ assert manager.get_ringtone_path("a\x00b.opus") is None
+ assert manager.get_ringtone_path("..") is None
+ assert manager.get_ringtone_path("") is None
+ # Drive/parent paths collapse to basename under the ringtone storage root.
+ for name in ("C:/Windows/x.opus", "../evil.opus", "C:/escape.opus"):
+ path = manager.get_ringtone_path(name)
+ assert path is not None
+ root = os.path.realpath(manager.storage_dir)
+ assert os.path.realpath(path).startswith(root + os.sep)
+ assert os.path.basename(path) in {"x.opus", "evil.opus", "escape.opus"}
+ assert manager.remove_ringtone("C:/escape.opus") is True
diff --git a/tests/backend/test_rrc_part_room.py b/tests/backend/test_rrc_part_room.py
index e01855f9..23affaef 100644
--- a/tests/backend/test_rrc_part_room.py
+++ b/tests/backend/test_rrc_part_room.py
@@ -4,10 +4,10 @@
Leaving a connected RRC room must drop it from the known-rooms list.
-`RRCHub.ordered_known_rooms()` treats any room with an entry in
-`self.messages` as "known", even if it was never joined. `part_room()`
+RRCHub.ordered_known_rooms() treats any room with an entry in
+self.messages as "known", even if it was never joined. part_room()
(used for a connected hub) previously only discarded the room from
-`self.rooms`, leaving its `messages` entry (and unread/member state)
+self.rooms, leaving its messages entry (and unread/member state)
behind, so the room kept reappearing in the sidebar after leaving it.
"""
diff --git a/tests/backend/test_safe_path_under_dir.py b/tests/backend/test_safe_path_under_dir.py
new file mode 100644
index 00000000..056a202e
--- /dev/null
+++ b/tests/backend/test_safe_path_under_dir.py
@@ -0,0 +1,23 @@
+# SPDX-License-Identifier: 0BSD
+
+import os
+
+from meshchatx.src.path_utils import safe_path_under_dir
+
+
+def test_safe_path_under_dir_accepts_basename(tmp_path):
+ target = tmp_path / "rec.opus"
+ target.write_bytes(b"x")
+ out = safe_path_under_dir(str(tmp_path), "rec.opus")
+ assert out == os.path.realpath(str(target))
+
+
+def test_safe_path_under_dir_collapses_traversal_to_basename(tmp_path):
+ out = safe_path_under_dir(str(tmp_path), "../evil.opus")
+ assert out == os.path.realpath(os.path.join(str(tmp_path), "evil.opus"))
+
+
+def test_safe_path_under_dir_rejects_nul_and_dot(tmp_path):
+ assert safe_path_under_dir(str(tmp_path), "a\x00b.opus") is None
+ assert safe_path_under_dir(str(tmp_path), "..") is None
+ assert safe_path_under_dir(str(tmp_path), "") is None
diff --git a/tests/backend/test_security_exploratory_oracle.py b/tests/backend/test_security_exploratory_oracle.py
index 73b0ab71..5d8ea78c 100644
--- a/tests/backend/test_security_exploratory_oracle.py
+++ b/tests/backend/test_security_exploratory_oracle.py
@@ -124,6 +124,17 @@ def _safe_image_type(raw) -> str:
return image_type
+def _resolve_served_image_type(image_data: bytes, declared=None) -> str | None:
+ """Mirror meshchat LXMF image serve: Content-Type from magic only."""
+ from meshchatx.src.backend.sticker_utils import detect_image_format_from_magic
+
+ allowed = {"png", "jpeg", "jpg", "gif", "webp", "bmp"}
+ detected = detect_image_format_from_magic(image_data)
+ if detected is None or detected not in allowed:
+ return None
+ return "jpeg" if detected == "jpeg" else detected
+
+
def _try_b64(raw):
if not isinstance(raw, str) or not raw:
return None
@@ -178,6 +189,8 @@ def test_oracle_known_bad_attachment_shapes_rejected():
assert _safe_image_type(99) == "png"
assert _safe_image_type("svg") == "png"
assert _safe_image_type("image/webp") == "webp"
+ assert _resolve_served_image_type(b"<html>") is None
+ assert _resolve_served_image_type(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) == "png"
# ---------------------------------------------------------------------------
diff --git a/tests/backend/test_telephone_csrf_methods.py b/tests/backend/test_telephone_csrf_methods.py
new file mode 100644
index 00000000..fa5cd421
--- /dev/null
+++ b/tests/backend/test_telephone_csrf_methods.py
@@ -0,0 +1,40 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Telephone call control mutators must be POST so CSRF middleware applies."""
+
+
+def _handler(app, method: str, path: str):
+ for route in app.get_routes():
+ if route.method == method and route.path == path:
+ return route.handler
+ return None
+
+
+def test_telephone_call_mutators_are_post_not_get(mock_app):
+ mutators = [
+ "/api/v1/telephone/answer",
+ "/api/v1/telephone/hangup",
+ "/api/v1/telephone/send-to-voicemail",
+ "/api/v1/telephone/mute-transmit",
+ "/api/v1/telephone/unmute-transmit",
+ "/api/v1/telephone/mute-receive",
+ "/api/v1/telephone/unmute-receive",
+ ]
+ for path in mutators:
+ assert _handler(mock_app, "POST", path) is not None, path
+ assert _handler(mock_app, "GET", path) is None, path
+
+ assert (
+ _handler(mock_app, "POST", "/api/v1/telephone/call/{identity_hash}") is not None
+ )
+ assert _handler(mock_app, "GET", "/api/v1/telephone/call/{identity_hash}") is None
+ assert (
+ _handler(
+ mock_app, "POST", "/api/v1/telephone/switch-audio-profile/{profile_id}"
+ )
+ is not None
+ )
+ assert (
+ _handler(mock_app, "GET", "/api/v1/telephone/switch-audio-profile/{profile_id}")
+ is None
+ )
diff --git a/tests/backend/test_translator_handler_extended.py b/tests/backend/test_translator_handler_extended.py
index 8c6cd307..92b1dc2d 100644
--- a/tests/backend/test_translator_handler_extended.py
+++ b/tests/backend/test_translator_handler_extended.py
@@ -255,7 +255,7 @@ def test_get_translator_languages_response_explicit_bad_override_raises():
translator_libretranslate_enabled=True,
)
handler.has_requests = True
- with pytest.raises(ValueError, match="IPv4 link-local"):
+ with pytest.raises(ValueError, match="link-local"):
handler.get_translator_languages_response(
libretranslate_url="http://169.254.169.254:5000",
)
diff --git a/tests/e2e/acceptance-settings-privacy.spec.js b/tests/e2e/acceptance-settings-privacy.spec.js
index 62fc4fa2..d1508793 100644
--- a/tests/e2e/acceptance-settings-privacy.spec.js
+++ b/tests/e2e/acceptance-settings-privacy.spec.js
@@ -24,8 +24,8 @@ test.describe("Acceptance: Settings privacy", () => {
await expect(page.getByText("Data & device", { exact: true }).first()).toBeVisible({
timeout: 20000,
});
- await expect(
- page.getByText("Privacy mode (block external HTTP/HTTPS)", { exact: true }).first(),
- ).toBeVisible({ timeout: 20000 });
+ await expect(page.getByText("Privacy mode (block external HTTP/HTTPS)", { exact: true }).first()).toBeVisible({
+ timeout: 20000,
+ });
});
});
diff --git a/tests/frontend/CallOverlay.test.js b/tests/frontend/CallOverlay.test.js
index 56d981f9..c8d88f92 100644
--- a/tests/frontend/CallOverlay.test.js
+++ b/tests/frontend/CallOverlay.test.js
@@ -160,7 +160,7 @@ describe("CallOverlay.vue", () => {
it("navigates to Call phone tab after answering", async () => {
const push = vi.fn().mockResolvedValue(undefined);
- global.api = { get: vi.fn().mockResolvedValue({}) };
+ global.api = { get: vi.fn().mockResolvedValue({}), post: vi.fn().mockResolvedValue({}) };
const wrapper = mount(CallOverlay, {
props: {
...defaultProps,
@@ -184,7 +184,7 @@ describe("CallOverlay.vue", () => {
},
});
await wrapper.vm.answerCall();
- expect(global.api.get).toHaveBeenCalledWith("/api/v1/telephone/answer");
+ expect(global.api.post).toHaveBeenCalledWith("/api/v1/telephone/answer");
expect(push).toHaveBeenCalledWith({ name: "call", query: { tab: "phone" } });
});
});
diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js
index 73a893d8..b9556792 100644
--- a/tests/frontend/CallPage.test.js
+++ b/tests/frontend/CallPage.test.js
@@ -127,7 +127,7 @@ describe("CallPage.vue", () => {
// Should be muted immediately (optimistic)
expect(wrapper.vm.activeCall.is_mic_muted).toBe(true);
- expect(axiosMock.get).toHaveBeenCalledWith(expect.stringContaining("/api/v1/telephone/mute-transmit"));
+ expect(axiosMock.post).toHaveBeenCalledWith(expect.stringContaining("/api/v1/telephone/mute-transmit"));
});
it("renders tabs correctly", async () => {
@@ -207,8 +207,8 @@ describe("CallPage.vue", () => {
const callButton = buttons.find((b) => b.text() === "Call");
if (callButton) {
await callButton.trigger("click");
- // CallPage.vue uses window.api.get(`/api/v1/telephone/call/${hashToCall}`)
- expect(axiosMock.get).toHaveBeenCalledWith(
+ // CallPage.vue uses window.api.post(`/api/v1/telephone/call/${hashToCall}`)
+ expect(axiosMock.post).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/telephone/call/test-destination")
);
} else {
@@ -265,7 +265,7 @@ describe("CallPage.vue", () => {
await wrapper.vm.$nextTick();
const hash32 = "ab".repeat(16);
await wrapper.vm.call(`Call me at ${hash32} please`);
- expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/telephone/call/${hash32}`);
+ expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/telephone/call/${hash32}`);
});
it("addContactFromHistory prefers identity hash over destination hashes", async () => {
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index a1947142..c87527ed 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -1527,12 +1527,14 @@ describe("ConversationViewer.vue", () => {
await wrapper.vm.$nextTick();
const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}");
- expect(drafts["a".repeat(32)]).toBe("draft for A");
+ expect(drafts["my-hash"]["a".repeat(32)]).toBe("draft for A");
});
it("loads the stored draft when opening a peer", async () => {
draftStore["meshchat.drafts"] = JSON.stringify({
- ["b".repeat(32)]: "remembered",
+ "my-hash": {
+ ["b".repeat(32)]: "remembered",
+ },
});
const wrapper = mountConversationViewer({
@@ -1543,6 +1545,37 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.vm.newMessageText).toBe("remembered");
});
+ it("loads legacy flat drafts and re-saves under the active identity", async () => {
+ draftStore["meshchat.drafts"] = JSON.stringify({
+ ["b".repeat(32)]: "legacy",
+ });
+
+ const wrapper = mountConversationViewer({
+ selectedPeer: { destination_hash: "b".repeat(32), display_name: "B" },
+ });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.newMessageText).toBe("legacy");
+
+ wrapper.vm.saveDraft("b".repeat(32));
+ const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}");
+ expect(drafts["my-hash"]["b".repeat(32)]).toBe("legacy");
+ expect(drafts["b".repeat(32)]).toBeUndefined();
+ });
+
+ it("keeps drafts isolated per local identity hash", async () => {
+ draftStore["meshchat.drafts"] = JSON.stringify({
+ "identity-a": { ["p".repeat(32)]: "from-a" },
+ "identity-b": { ["p".repeat(32)]: "from-b" },
+ });
+
+ const wrapper = mountConversationViewer({
+ selectedPeer: { destination_hash: "p".repeat(32), display_name: "P" },
+ config: { identity_hash: "identity-b" },
+ });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.newMessageText).toBe("from-b");
+ });
+
it("round-trips drafts for A then B then back to A", async () => {
const peerA = { destination_hash: "a".repeat(32), display_name: "A" };
const peerB = { destination_hash: "b".repeat(32), display_name: "B" };
@@ -1566,7 +1599,9 @@ describe("ConversationViewer.vue", () => {
it("removes the draft key when saving an empty compose box for that peer", async () => {
draftStore["meshchat.drafts"] = JSON.stringify({
- ["a".repeat(32)]: "will clear",
+ "my-hash": {
+ ["a".repeat(32)]: "will clear",
+ },
});
const wrapper = mountConversationViewer({
@@ -1578,7 +1613,7 @@ describe("ConversationViewer.vue", () => {
wrapper.vm.saveDraft("a".repeat(32));
const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}");
- expect(drafts["a".repeat(32)]).toBeUndefined();
+ expect(drafts["my-hash"]["a".repeat(32)]).toBeUndefined();
});
it("persists the current compose text when the component unmounts", async () => {
@@ -1587,10 +1622,27 @@ describe("ConversationViewer.vue", () => {
await wrapper.vm.$nextTick();
wrapper.vm.newMessageText = "save on leave";
- wrapper.unmount();
+ await wrapper.unmount();
const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}");
- expect(drafts["a".repeat(32)]).toBe("save on leave");
+ expect(drafts["my-hash"]["a".repeat(32)]).toBe("save on leave");
+ });
+
+ it("clears chat items and audio cache on identity-switched", async () => {
+ const wrapper = mountConversationViewer({
+ selectedPeer: { destination_hash: "a".repeat(32), display_name: "A" },
+ });
+ await wrapper.vm.$nextTick();
+ wrapper.vm.chatItems = [{ lxmf_message: { hash: "deadbeef" } }];
+ wrapper.vm.lxmfMessageAudioAttachmentCache = { x: "blob:fake" };
+ const clearSpy = vi.spyOn(wrapper.vm, "clearAudioAttachmentCache");
+ const loadSpy = vi.spyOn(wrapper.vm, "initialLoad").mockResolvedValue(undefined);
+
+ wrapper.vm.onIdentitySwitched();
+
+ expect(wrapper.vm.chatItems).toEqual([]);
+ expect(clearSpy).toHaveBeenCalled();
+ expect(loadSpy).toHaveBeenCalled();
});
});
diff --git a/tests/frontend/NomadPageRenderer.security.test.js b/tests/frontend/NomadPageRenderer.security.test.js
index 1dda91d7..31ce6e99 100644
--- a/tests/frontend/NomadPageRenderer.security.test.js
+++ b/tests/frontend/NomadPageRenderer.security.test.js
@@ -121,12 +121,13 @@ describe("NomadPageRenderer HTML document sanitization", () => {
expect(html).not.toContain("evil.com");
});
- it("strips external img src except safe data:image", () => {
+ it("strips external img src except safe raster data:image", () => {
const html = renderNomadHtmlPage(
- '<body><img src="https://evil.com/i.png"><img src="data:image/png;base64,iVBORw0KGgo="></body>'
+ '<body><img src="https://evil.com/i.png"><img src="data:image/png;base64,iVBORw0KGgo="><img src="data:image/svg+xml,<svg></svg>"></body>'
);
expect(html).not.toContain("evil.com");
expect(html).toContain("data:image/png");
+ expect(html.toLowerCase()).not.toContain("svg+xml");
});
it("sanitises style text with network url in document", () => {
diff --git a/tests/frontend/RNSHManagerPage.test.js b/tests/frontend/RNSHManagerPage.test.js
index 740ceedf..bde26c3f 100644
--- a/tests/frontend/RNSHManagerPage.test.js
+++ b/tests/frontend/RNSHManagerPage.test.js
@@ -133,7 +133,7 @@ describe("RNSHManagerPage.vue", () => {
expect(wrapper.vm.mobileSessionsOpen).toBe(false);
});
- it("creates a listen session with no_auth enabled by default", async () => {
+ it("creates a listen session with no_auth disabled by default", async () => {
window.api.post.mockResolvedValueOnce({
data: { session: makeSession({ id: "listen-1", mode: "listen", name: "Listener" }) },
});
@@ -141,7 +141,7 @@ describe("RNSHManagerPage.vue", () => {
const wrapper = mount(RNSHManagerPage, { global: mountToolsPageGlobals() });
await vi.waitFor(() => expect(wrapper.vm.sessions.length).toBe(1));
- expect(wrapper.vm.listenForm.no_auth).toBe(true);
+ expect(wrapper.vm.listenForm.no_auth).toBe(false);
wrapper.vm.listenForm.name = "Listener";
await wrapper.vm.createListenSession();
@@ -151,7 +151,7 @@ describe("RNSHManagerPage.vue", () => {
allowed_hashes: [],
default_command: undefined,
config_path: undefined,
- no_auth: true,
+ no_auth: false,
autostart: true,
});
});
diff --git a/tests/frontend/archivesPage.security.test.js b/tests/frontend/archivesPage.security.test.js
index 3fdc12d2..7bc8ec54 100644
--- a/tests/frontend/archivesPage.security.test.js
+++ b/tests/frontend/archivesPage.security.test.js
@@ -65,13 +65,27 @@ describe("Archives page viewing-archive surface (security / fuzz)", () => {
const out = wrapper.vm.renderFullContent({
page_path: "/page/evil.html",
content: '<body><img src=x onerror=alert(1)><a href="javascript:alert(1)">x</a></body>',
- destination_hash: "a".repeat(64),
- hash: "b".repeat(64),
+ destination_hash: "a".repeat(32),
+ hash: "b".repeat(32),
id: 1,
});
assertNoDangerousHtmlPatterns(out);
});
+ it("heuristic micron archives isolate http and mesh links", () => {
+ const { wrapper } = mountArchives();
+ const hash = "aa".repeat(16);
+ const out = wrapper.vm.renderFullContent({
+ page_path: "/forum/thread",
+ content:
+ "`Hi`\n`[Phish`http://evil.example/login]`\n`[Node`bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:/page/index.mu]`",
+ destination_hash: hash,
+ id: 2,
+ });
+ expect(out.toLowerCase()).not.toMatch(/href\s*=\s*["']?\s*https?:/i);
+ expect(out).toMatch(/data-action\s*=\s*["']openNode["']/i);
+ });
+
it("renderFullContent never throws; returns a string for fuzzed paths and bodies", () => {
const { wrapper } = mountArchives();
for (let i = 0; i < 90; i++) {
diff --git a/tests/frontend/hotPathBugRegressions.test.js b/tests/frontend/hotPathBugRegressions.test.js
index e7505287..164a174f 100644
--- a/tests/frontend/hotPathBugRegressions.test.js
+++ b/tests/frontend/hotPathBugRegressions.test.js
@@ -10,6 +10,18 @@ import { renderNomadHtmlPage, stripOverlayFromCss } from "@/js/NomadPageRenderer
import DownloadUtils from "@/js/DownloadUtils.js";
describe("hot-path bug regressions", () => {
+ it("strips absolute full-bleed overlays and rejects svg data images", () => {
+ const css = stripOverlayFromCss(".x{position:absolute;top:0;left:0;width:100%;height:100%;z-index:9}");
+ expect(css.toLowerCase()).not.toMatch(/position\s*:\s*absolute/);
+ expect(css.toLowerCase()).toMatch(/position:static/);
+ const svg =
+ "data:image/svg+xml," +
+ encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>');
+ const html = renderNomadHtmlPage(`<img src="${svg}"><img src="data:image/png;base64,aaa">`);
+ expect(html.toLowerCase()).not.toContain("svg+xml");
+ expect(html).toContain("data:image/png");
+ });
+
it("strips single-quoted position:fixed overlays", () => {
const out = MicronParser.stripOverlayStyles(`<div style='position:fixed; color:red'>x</div>`);
expect(out.toLowerCase()).not.toMatch(/position\s*:\s*fixed/);
diff --git a/tests/frontend/securityExploratoryOracle.test.js b/tests/frontend/securityExploratoryOracle.test.js
index 046ced49..d5cec879 100644
--- a/tests/frontend/securityExploratoryOracle.test.js
+++ b/tests/frontend/securityExploratoryOracle.test.js
@@ -53,6 +53,7 @@ function assertSafeHtmlOracle(html) {
expect(lower).not.toMatch(/\bhref\s*=\s*["']?\s*vbscript\s*:/);
expect(lower).not.toMatch(/position\s*:\s*fixed/);
expect(lower).not.toMatch(/position\s*:\s*sticky/);
+ expect(lower).not.toMatch(/position\s*:\s*absolute/);
}
function randomStyleValue(len) {
diff --git a/tests/test_android_emulator_smoke_script.py b/tests/test_android_emulator_smoke_script.py
index 0ce4c86a..a203a480 100644
--- a/tests/test_android_emulator_smoke_script.py
+++ b/tests/test_android_emulator_smoke_script.py
@@ -41,7 +41,7 @@ def test_workflow_references_smoke_script():
assert "MESHCHATX_ABIS" in workflow
assert "x86_64" in workflow
# Runner executes each script: line with /usr/bin/sh (dash). A bare
- # `set -euo pipefail` there fails with "Illegal option -o pipefail".
+ # set -euo pipefail there fails with "Illegal option -o pipefail".
smoke_step = workflow.split("name: Run emulator smoke", 1)[1]
smoke_script = smoke_step.split("script:", 1)[1].split("\n", 1)[0]
assert "bash scripts/ci/android-emulator-smoke.sh" in smoke_script
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────